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, parse_source_with_cached_parser, 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        if let Some(scope) = crate::logging::current_index_build() {
729            crate::logging::log_index_event(
730                crate::logging::IndexEvent::from_scope(
731                    crate::logging::IndexEventKind::BuildProgress,
732                    &scope,
733                )
734                .field("stage", stage)
735                .field("completed", completed)
736                .field("total", total)
737                .field("elapsed_ms", scope.elapsed_ms()),
738            );
739        }
740        return Ok(());
741    }
742    crate::slog_info!(
743        "callgraph cold build superseded, stopping after {}/{} ({})",
744        completed,
745        total,
746        stage
747    );
748    if let Some(scope) = crate::logging::current_index_build() {
749        crate::logging::log_index_event(
750            crate::logging::IndexEvent::from_scope(
751                crate::logging::IndexEventKind::BuildSuperseded,
752                &scope,
753            )
754            .field("stage", stage)
755            .field("completed", completed)
756            .field("total", total),
757        );
758    }
759    Err(CallGraphStoreError::Superseded)
760}
761
762fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
763    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
764    match admission {
765        Some((epoch, expected)) => epoch
766            .run_if_current(expected, publish)
767            .unwrap_or(Err(CallGraphStoreError::Superseded)),
768        None => publish(),
769    }
770}
771
772struct RefreshCommitAdmissionGuard {
773    previous: Option<(
774        SubcLifecycleAdmission,
775        Arc<std::sync::atomic::AtomicU64>,
776        u64,
777    )>,
778}
779
780impl Drop for RefreshCommitAdmissionGuard {
781    fn drop(&mut self) {
782        REFRESH_COMMIT_ADMISSION.with(|slot| {
783            *slot.borrow_mut() = self.previous.take();
784        });
785    }
786}
787
788fn with_refresh_commit_admission<R>(
789    lifecycle: SubcLifecycleAdmission,
790    generation_flag: Arc<std::sync::atomic::AtomicU64>,
791    expected_generation: u64,
792    run: impl FnOnce() -> R,
793) -> R {
794    let previous = REFRESH_COMMIT_ADMISSION
795        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
796    let _guard = RefreshCommitAdmissionGuard { previous };
797    run()
798}
799
800fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
801    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
802    let commit = || {
803        publish_if_current(|| {
804            tx.commit()?;
805            Ok(())
806        })
807    };
808    match admission {
809        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
810            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
811            .unwrap_or(Err(CallGraphStoreError::Superseded)),
812        None => commit(),
813    }
814}
815
816fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
817    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
818    if let Some(observer) = observer {
819        observer(temp_path, target_path);
820    }
821}
822
823#[derive(Debug)]
824pub enum CallGraphStoreError {
825    Io(std::io::Error),
826    Sqlite(rusqlite::Error),
827    Json(serde_json::Error),
828    Aft(AftError),
829    Lock(crate::fs_lock::AcquireError),
830    MissingCallerData {
831        file: String,
832    },
833    Unavailable(String),
834    PathIdentityMismatch {
835        path: PathBuf,
836        project_root: PathBuf,
837    },
838    Suspended(crate::build_breaker::BuildSuspension),
839    Superseded,
840    StaleFiles(Vec<String>),
841}
842
843impl CallGraphStoreError {
844    pub(crate) fn is_transient_lock_contention(&self) -> bool {
845        matches!(
846            self,
847            Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
848                if matches!(
849                    error.code,
850                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
851                )
852        )
853    }
854}
855
856impl fmt::Display for CallGraphStoreError {
857    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
858        match self {
859            Self::Io(error) => write!(formatter, "I/O error: {error}"),
860            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
861            Self::Json(error) => write!(formatter, "json error: {error}"),
862            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
863            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
864            Self::MissingCallerData { file } => {
865                write!(formatter, "missing extracted caller data for {file}")
866            }
867            Self::Unavailable(message) => {
868                write!(formatter, "callgraph store unavailable: {message}")
869            }
870            Self::PathIdentityMismatch { path, project_root } => write!(
871                formatter,
872                "callgraph path identity mismatch: {} is not under project root {}",
873                path.display(),
874                project_root.display()
875            ),
876            Self::Suspended(suspension) => write!(
877                formatter,
878                "callgraph build suspended for {} after {} deaths ({})",
879                suspension.domain.as_str(),
880                suspension.death_count,
881                suspension.reason
882            ),
883            Self::Superseded => {
884                write!(formatter, "callgraph store build superseded before publish")
885            }
886            Self::StaleFiles(files) => {
887                write!(
888                    formatter,
889                    "callgraph store has stale files: {}",
890                    files.join(", ")
891                )
892            }
893        }
894    }
895}
896
897impl std::error::Error for CallGraphStoreError {}
898
899impl From<std::io::Error> for CallGraphStoreError {
900    fn from(error: std::io::Error) -> Self {
901        Self::Io(error)
902    }
903}
904
905impl From<rusqlite::Error> for CallGraphStoreError {
906    fn from(error: rusqlite::Error) -> Self {
907        Self::Sqlite(error)
908    }
909}
910
911impl From<serde_json::Error> for CallGraphStoreError {
912    fn from(error: serde_json::Error) -> Self {
913        Self::Json(error)
914    }
915}
916
917impl From<AftError> for CallGraphStoreError {
918    fn from(error: AftError) -> Self {
919        Self::Aft(error)
920    }
921}
922
923impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
924    fn from(error: crate::fs_lock::AcquireError) -> Self {
925        Self::Lock(error)
926    }
927}
928
929pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
930
931/// Config flag name gating whether the store is opened (default on). Production
932/// commands open it through `open_if_enabled` so the substrate can be disabled
933/// without code changes.
934pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
935
936#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
937pub struct CallGraphStoreOptions {
938    pub enabled: bool,
939}
940
941pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
942
943/// Shared context state that lets the refresh worker observe a store installed
944/// after its batch was opened. The worker clones the installed store Arc before
945/// checking it, so no context lock guard crosses the check or enqueue call.
946#[derive(Clone)]
947pub(crate) struct CallgraphRefreshState {
948    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
949    heavy_root_work_allowed: Arc<AtomicBool>,
950}
951
952impl CallgraphRefreshState {
953    pub(crate) fn new(
954        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
955        heavy_root_work_allowed: Arc<AtomicBool>,
956    ) -> Self {
957        Self {
958            store,
959            heavy_root_work_allowed,
960        }
961    }
962
963    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
964        self.store
965            .read()
966            .unwrap_or_else(std::sync::PoisonError::into_inner)
967            .as_ref()
968            .map(Arc::clone)
969    }
970}
971
972type WorkspaceCratePrefixes = HashMap<String, String>;
973
974#[derive(Clone, Debug, Default)]
975struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
976
977const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
978
979pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
980    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
981}
982
983#[derive(Clone, Debug, Hash, PartialEq, Eq)]
984struct RefreshRoot {
985    callgraph_dir: PathBuf,
986    project_root: PathBuf,
987}
988
989#[derive(Clone)]
990pub(crate) struct CallgraphRefreshTicket {
991    lifecycle: SubcLifecycleAdmission,
992    generation_flag: Arc<std::sync::atomic::AtomicU64>,
993    expected_generation: u64,
994    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
995    expected_publish_epoch: u64,
996}
997
998impl CallgraphRefreshTicket {
999    pub(crate) fn new(
1000        lifecycle: SubcLifecycleAdmission,
1001        generation_flag: Arc<std::sync::atomic::AtomicU64>,
1002        expected_generation: u64,
1003        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1004        expected_publish_epoch: u64,
1005    ) -> Self {
1006        Self {
1007            lifecycle,
1008            generation_flag,
1009            expected_generation,
1010            publish_epoch,
1011            expected_publish_epoch,
1012        }
1013    }
1014
1015    fn is_current(&self) -> bool {
1016        self.lifecycle
1017            .is_current(self.generation_flag.as_ref(), self.expected_generation)
1018            && self.publish_epoch.current() == self.expected_publish_epoch
1019    }
1020}
1021
1022#[derive(Clone)]
1023struct RefreshBatch {
1024    root: RefreshRoot,
1025    paths: BTreeSet<PathBuf>,
1026    pending_sinks: Vec<PendingCallGraphStorePaths>,
1027    refresh_states: Vec<CallgraphRefreshState>,
1028    ticket: Option<CallgraphRefreshTicket>,
1029}
1030
1031impl RefreshBatch {
1032    fn defer(&self) {
1033        for sink in &self.pending_sinks {
1034            sink.lock().extend(self.paths.iter().cloned());
1035        }
1036    }
1037
1038    fn defer_after_open_failure(&self) {
1039        self.defer();
1040        if self
1041            .ticket
1042            .as_ref()
1043            .is_some_and(|ticket| !ticket.is_current())
1044            || !self
1045                .refresh_states
1046                .iter()
1047                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1048        {
1049            return;
1050        }
1051
1052        let ready_store_installed = self.refresh_states.iter().any(|state| {
1053            let store = state.installed_store_snapshot();
1054            store.is_some_and(|store| {
1055                store.project_root() == self.root.project_root
1056                    && !store.is_legacy_fallback()
1057                    && store.is_current()
1058            })
1059        });
1060        if !ready_store_installed {
1061            return;
1062        }
1063
1064        // This re-check and the ready-store install's pending-sink take form a
1065        // check-then-act handoff: after this defer, exactly one site observes
1066        // the parked paths with a ready current store, so no polling is needed.
1067        for sink in &self.pending_sinks {
1068            let paths = {
1069                let mut pending = sink.lock();
1070                self.paths
1071                    .iter()
1072                    .filter(|path| pending.remove(*path))
1073                    .cloned()
1074                    .collect::<Vec<_>>()
1075            };
1076            if paths.is_empty() {
1077                continue;
1078            }
1079            let _ = enqueue_callgraph_store_refresh_inner(
1080                self.root.callgraph_dir.clone(),
1081                self.root.project_root.clone(),
1082                paths,
1083                Arc::clone(sink),
1084                self.refresh_states.clone(),
1085                self.ticket.clone(),
1086            );
1087        }
1088    }
1089
1090    fn merge(
1091        &mut self,
1092        paths: impl IntoIterator<Item = PathBuf>,
1093        sink: PendingCallGraphStorePaths,
1094        refresh_states: Vec<CallgraphRefreshState>,
1095        ticket: Option<CallgraphRefreshTicket>,
1096    ) {
1097        self.paths.extend(paths);
1098        if ticket.is_some() {
1099            self.ticket = ticket;
1100        }
1101        if !self
1102            .pending_sinks
1103            .iter()
1104            .any(|existing| Arc::ptr_eq(existing, &sink))
1105        {
1106            self.pending_sinks.push(sink);
1107        }
1108        for refresh_state in refresh_states {
1109            if !self.refresh_states.iter().any(|existing| {
1110                Arc::ptr_eq(&existing.store, &refresh_state.store)
1111                    && Arc::ptr_eq(
1112                        &existing.heavy_root_work_allowed,
1113                        &refresh_state.heavy_root_work_allowed,
1114                    )
1115            }) {
1116                self.refresh_states.push(refresh_state);
1117            }
1118        }
1119    }
1120}
1121
1122#[derive(Default)]
1123struct RefreshQueue {
1124    order: VecDeque<RefreshRoot>,
1125    queued: HashMap<RefreshRoot, RefreshBatch>,
1126    active: Option<RefreshBatch>,
1127    shutdown_requested: bool,
1128}
1129
1130struct RefreshWorkerShared {
1131    queue: Mutex<RefreshQueue>,
1132    wake: Condvar,
1133}
1134
1135struct RefreshWorker {
1136    shared: Arc<RefreshWorkerShared>,
1137    thread: Mutex<Option<JoinHandle<()>>>,
1138}
1139
1140struct RefreshWorkerWatchdog {
1141    first_path: PathBuf,
1142    batch_len: usize,
1143    started: Instant,
1144}
1145
1146impl RefreshWorkerWatchdog {
1147    fn start(paths: &[PathBuf]) -> Self {
1148        Self {
1149            first_path: paths
1150                .first()
1151                .expect("non-empty callgraph refresh batch has a first path")
1152                .clone(),
1153            batch_len: paths.len(),
1154            started: Instant::now(),
1155        }
1156    }
1157}
1158
1159impl Drop for RefreshWorkerWatchdog {
1160    fn drop(&mut self) {
1161        let elapsed = self.started.elapsed();
1162        if elapsed < REFRESH_WORKER_WARN_AFTER {
1163            return;
1164        }
1165        let path = if self.batch_len == 1 {
1166            self.first_path.display().to_string()
1167        } else {
1168            format!(
1169                "{} (+{} paths)",
1170                self.first_path.display(),
1171                self.batch_len - 1
1172            )
1173        };
1174        log::warn!(
1175            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1176            path,
1177            elapsed.as_millis()
1178        );
1179        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1180            log::warn!(
1181                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1182                path,
1183                elapsed.as_millis()
1184            );
1185        }
1186    }
1187}
1188
1189impl RefreshWorker {
1190    fn spawn() -> Arc<Self> {
1191        let shared = Arc::new(RefreshWorkerShared {
1192            queue: Mutex::new(RefreshQueue::default()),
1193            wake: Condvar::new(),
1194        });
1195        let thread_shared = Arc::clone(&shared);
1196        let thread = std::thread::Builder::new()
1197            .name("aft-callgraph-refresh".to_string())
1198            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1199            .expect("failed to spawn callgraph refresh worker");
1200        Arc::new(Self {
1201            shared,
1202            thread: Mutex::new(Some(thread)),
1203        })
1204    }
1205
1206    fn enqueue(
1207        &self,
1208        root: RefreshRoot,
1209        paths: Vec<PathBuf>,
1210        pending_sink: PendingCallGraphStorePaths,
1211        refresh_states: Vec<CallgraphRefreshState>,
1212        ticket: Option<CallgraphRefreshTicket>,
1213    ) -> bool {
1214        let mut queue = self
1215            .shared
1216            .queue
1217            .lock()
1218            .expect("callgraph refresh queue mutex poisoned");
1219        if queue.shutdown_requested {
1220            pending_sink.lock().extend(paths);
1221            return false;
1222        }
1223        if let Some(batch) = queue.queued.get_mut(&root) {
1224            batch.merge(paths, pending_sink, refresh_states, ticket);
1225        } else {
1226            queue.order.push_back(root.clone());
1227            queue.queued.insert(
1228                root.clone(),
1229                RefreshBatch {
1230                    root,
1231                    paths: paths.into_iter().collect(),
1232                    pending_sinks: vec![pending_sink],
1233                    refresh_states,
1234                    ticket,
1235                },
1236            );
1237        }
1238        self.shared.wake.notify_one();
1239        true
1240    }
1241
1242    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1243        let deadline = Instant::now() + budget;
1244        let mut queue = self
1245            .shared
1246            .queue
1247            .lock()
1248            .expect("callgraph refresh queue mutex poisoned");
1249        queue.shutdown_requested = true;
1250        self.shared.wake.notify_one();
1251        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1252            let remaining = deadline.saturating_duration_since(Instant::now());
1253            let (next, _) = self
1254                .shared
1255                .wake
1256                .wait_timeout(queue, remaining)
1257                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1258            queue = next;
1259        }
1260        let drained = queue.active.is_none() && queue.order.is_empty();
1261        if !drained {
1262            if let Some(active) = queue.active.as_ref() {
1263                active.defer();
1264            }
1265            for batch in queue.queued.values() {
1266                batch.defer();
1267            }
1268            queue.order.clear();
1269            queue.queued.clear();
1270        }
1271        drop(queue);
1272
1273        if drained {
1274            if let Some(thread) = self
1275                .thread
1276                .lock()
1277                .expect("callgraph refresh worker thread mutex poisoned")
1278                .take()
1279            {
1280                let _ = thread.join();
1281            }
1282        }
1283        drained
1284    }
1285}
1286
1287static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1288
1289pub fn enqueue_callgraph_store_refresh(
1290    callgraph_dir: PathBuf,
1291    project_root: PathBuf,
1292    paths: Vec<PathBuf>,
1293    pending_sink: PendingCallGraphStorePaths,
1294) -> bool {
1295    enqueue_callgraph_store_refresh_inner(
1296        callgraph_dir,
1297        project_root,
1298        paths,
1299        pending_sink,
1300        Vec::new(),
1301        None,
1302    )
1303}
1304
1305#[cfg(test)]
1306pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1307    callgraph_dir: PathBuf,
1308    project_root: PathBuf,
1309    paths: Vec<PathBuf>,
1310    pending_sink: PendingCallGraphStorePaths,
1311    ticket: CallgraphRefreshTicket,
1312) -> bool {
1313    enqueue_callgraph_store_refresh_inner(
1314        callgraph_dir,
1315        project_root,
1316        paths,
1317        pending_sink,
1318        Vec::new(),
1319        Some(ticket),
1320    )
1321}
1322
1323pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1324    callgraph_dir: PathBuf,
1325    project_root: PathBuf,
1326    paths: Vec<PathBuf>,
1327    pending_sink: PendingCallGraphStorePaths,
1328    refresh_state: CallgraphRefreshState,
1329    ticket: CallgraphRefreshTicket,
1330) -> bool {
1331    enqueue_callgraph_store_refresh_inner(
1332        callgraph_dir,
1333        project_root,
1334        paths,
1335        pending_sink,
1336        vec![refresh_state],
1337        Some(ticket),
1338    )
1339}
1340
1341fn enqueue_callgraph_store_refresh_inner(
1342    callgraph_dir: PathBuf,
1343    project_root: PathBuf,
1344    paths: Vec<PathBuf>,
1345    pending_sink: PendingCallGraphStorePaths,
1346    refresh_states: Vec<CallgraphRefreshState>,
1347    ticket: Option<CallgraphRefreshTicket>,
1348) -> bool {
1349    if paths.is_empty() {
1350        return true;
1351    }
1352    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1353    let worker = {
1354        let mut worker = slot
1355            .lock()
1356            .expect("callgraph refresh worker mutex poisoned");
1357        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1358    };
1359    worker.enqueue(
1360        RefreshRoot {
1361            callgraph_dir,
1362            project_root,
1363        },
1364        paths,
1365        pending_sink,
1366        refresh_states,
1367        ticket,
1368    )
1369}
1370
1371pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1372    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1373}
1374
1375#[doc(hidden)]
1376pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1377    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1378    let worker = slot
1379        .lock()
1380        .expect("callgraph refresh worker mutex poisoned")
1381        .clone();
1382    let Some(worker) = worker else {
1383        return true;
1384    };
1385    let drained = worker.shutdown_with_budget(budget);
1386    if drained {
1387        let mut current = slot
1388            .lock()
1389            .expect("callgraph refresh worker mutex poisoned");
1390        if current
1391            .as_ref()
1392            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1393        {
1394            *current = None;
1395        }
1396    }
1397    drained
1398}
1399
1400fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1401    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1402}
1403
1404fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1405    // The worker owns these caches so maps are shared only by refreshes for the
1406    // same canonical root and disappear when the worker shuts down.
1407    let mut workspace_crate_prefixes = HashMap::new();
1408    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1409    loop {
1410        let batch = {
1411            let mut queue = shared
1412                .queue
1413                .lock()
1414                .expect("callgraph refresh queue mutex poisoned");
1415            loop {
1416                if let Some(root) = queue.order.pop_front() {
1417                    let batch = queue
1418                        .queued
1419                        .remove(&root)
1420                        .expect("queued callgraph refresh root has a batch");
1421                    queue.active = Some(batch.clone());
1422                    break batch;
1423                }
1424                if queue.shutdown_requested {
1425                    return;
1426                }
1427                queue = shared
1428                    .wake
1429                    .wait(queue)
1430                    .expect("callgraph refresh queue mutex poisoned while waiting");
1431            }
1432        };
1433
1434        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1435
1436        let mut queue = shared
1437            .queue
1438            .lock()
1439            .expect("callgraph refresh queue mutex poisoned");
1440        queue.active = None;
1441        let became_idle = queue.order.is_empty();
1442        shared.wake.notify_all();
1443        drop(queue);
1444
1445        if became_idle {
1446            let checkpoint_due = idle_checkpoint_due(
1447                last_idle_checkpoints.get(&batch.root).copied(),
1448                Instant::now(),
1449            );
1450            if checkpoint_due {
1451                if let Some(store) = store {
1452                    if store.checkpoint_wal_truncate() {
1453                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1454                    }
1455                }
1456            }
1457        }
1458    }
1459}
1460
1461fn process_callgraph_refresh_batch(
1462    batch: &RefreshBatch,
1463    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1464) -> Option<CallGraphStore> {
1465    // A manifest event is an invalidation signal, not a source file to parse.
1466    // Drop the root's map even for a superseded batch: the filesystem changed,
1467    // and a later configure must never inherit crate membership from before it.
1468    if batch
1469        .paths
1470        .iter()
1471        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1472    {
1473        workspace_crate_prefixes.remove(&batch.root);
1474    }
1475
1476    let paths = batch
1477        .paths
1478        .iter()
1479        .filter(|path| crate::parser::detect_language(path).is_some())
1480        .cloned()
1481        .collect::<Vec<_>>();
1482    if paths.is_empty() {
1483        return None;
1484    }
1485    note_refresh_worker_batch_for_test(&batch.root.project_root);
1486    if batch
1487        .ticket
1488        .as_ref()
1489        .is_some_and(|ticket| !ticket.is_current())
1490    {
1491        // Superseded before starting: park the paths so the next configure's
1492        // pending replay (or unbind cleanup) decides their fate.
1493        batch.defer();
1494        return None;
1495    }
1496    let workspace_crate_prefix_cache =
1497        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1498    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1499    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1500    note_refresh_worker_call_for_test(&batch.root.project_root);
1501    let opened = if test_seam.fail_open {
1502        Ok(None)
1503    } else {
1504        CallGraphStore::open_ready(
1505            batch.root.callgraph_dir.clone(),
1506            batch.root.project_root.clone(),
1507        )
1508    };
1509    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1510        // The gate is deliberately after open_ready so tests can hold a failed
1511        // open between its result and the defer that parks the batch.
1512        let _ = gate.held_tx.send(());
1513        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1514    }
1515    let store = match opened {
1516        Ok(Some(store)) => store,
1517        Ok(None) => {
1518            batch.defer_after_open_failure();
1519            return None;
1520        }
1521        Err(error) => {
1522            batch.defer_after_open_failure();
1523            crate::slog_warn!(
1524                "callgraph store writer open failed during refresh; deferred paths: {}",
1525                error
1526            );
1527            return None;
1528        }
1529    };
1530    if !test_seam.delay.is_zero() {
1531        std::thread::sleep(test_seam.delay);
1532    }
1533    if batch
1534        .ticket
1535        .as_ref()
1536        .is_some_and(|ticket| !ticket.is_current())
1537    {
1538        // This is a superseded-ticket defer, not an open-failure defer: leave
1539        // the paths for the replacement configure instead of self-replaying.
1540        batch.defer();
1541        return Some(store);
1542    }
1543    let refresh_result = if test_seam.fail_refresh {
1544        Err(CallGraphStoreError::Unavailable(
1545            "injected refresh worker failure".to_string(),
1546        ))
1547    } else if let Some(ticket) = &batch.ticket {
1548        with_publish_epoch(
1549            ticket.publish_epoch.clone(),
1550            ticket.expected_publish_epoch,
1551            || {
1552                with_refresh_commit_admission(
1553                    ticket.lifecycle.clone(),
1554                    Arc::clone(&ticket.generation_flag),
1555                    ticket.expected_generation,
1556                    || {
1557                        store
1558                            .refresh_files_with_workspace_crate_prefix_cache(
1559                                &paths,
1560                                workspace_crate_prefix_cache.clone(),
1561                            )
1562                            .map(|_| ())
1563                    },
1564                )
1565            },
1566        )
1567    } else {
1568        store
1569            .refresh_files_with_workspace_crate_prefix_cache(
1570                &paths,
1571                workspace_crate_prefix_cache.clone(),
1572            )
1573            .map(|_| ())
1574    };
1575    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1576        // The commit lost the fence race: a newer configure or publication
1577        // owns the store now. Defer instead of stale-marking — the paths were
1578        // never committed, and the replacement generation re-indexes them.
1579        batch.defer();
1580        return Some(store);
1581    }
1582    if let Err(error) = refresh_result {
1583        crate::slog_warn!("callgraph store refresh failed: {}", error);
1584        match store.mark_files_stale(&paths) {
1585            Ok(marked) => {
1586                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1587                crate::slog_warn!(
1588                    "marked {} callgraph store file(s) stale after refresh failure",
1589                    marked.len()
1590                );
1591            }
1592            Err(mark_error) => crate::slog_warn!(
1593                "failed to mark callgraph store files stale after refresh failure: {}",
1594                mark_error
1595            ),
1596        }
1597    } else {
1598        crate::logging::note_callgraph_invalidations(paths.len());
1599    }
1600    Some(store)
1601}
1602
1603fn workspace_crate_prefix_cache_for_root(
1604    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1605    root: &RefreshRoot,
1606) -> WorkspaceCratePrefixCache {
1607    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1608        // Eviction only costs a future rebuild; it cannot make resolution stale.
1609        if let Some(evicted) = caches.keys().next().cloned() {
1610            caches.remove(&evicted);
1611        }
1612    }
1613    caches.entry(root.clone()).or_default().clone()
1614}
1615
1616#[derive(Clone, Copy, Default)]
1617struct RefreshWorkerTestSeam {
1618    delay: Duration,
1619    fail_refresh: bool,
1620    fail_open: bool,
1621    refresh_calls: usize,
1622    worker_calls: usize,
1623    stale_marks: usize,
1624}
1625
1626static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1627    OnceLock::new();
1628
1629struct RefreshWorkerTestGate {
1630    held_tx: crossbeam_channel::Sender<()>,
1631    release_rx: crossbeam_channel::Receiver<()>,
1632}
1633
1634static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1635    OnceLock::new();
1636
1637#[doc(hidden)]
1638pub fn install_callgraph_refresh_worker_test_gate(
1639    project_root: PathBuf,
1640) -> (
1641    crossbeam_channel::Receiver<()>,
1642    crossbeam_channel::Sender<()>,
1643) {
1644    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1645    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1646    REFRESH_WORKER_TEST_GATES
1647        .get_or_init(|| Mutex::new(HashMap::new()))
1648        .lock()
1649        .expect("callgraph refresh test gate mutex poisoned")
1650        .insert(
1651            project_root,
1652            RefreshWorkerTestGate {
1653                held_tx,
1654                release_rx,
1655            },
1656        );
1657    (held_rx, release_tx)
1658}
1659
1660fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1661    REFRESH_WORKER_TEST_GATES
1662        .get_or_init(|| Mutex::new(HashMap::new()))
1663        .lock()
1664        .expect("callgraph refresh test gate mutex poisoned")
1665        .remove(project_root)
1666}
1667
1668fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1669    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1670        return RefreshWorkerTestSeam::default();
1671    };
1672    seams
1673        .lock()
1674        .expect("callgraph refresh test seam mutex poisoned")
1675        .get(project_root)
1676        .copied()
1677        .unwrap_or_default()
1678}
1679
1680fn note_refresh_worker_batch_for_test(project_root: &Path) {
1681    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1682        if let Some(seam) = seams
1683            .lock()
1684            .expect("callgraph refresh test seam mutex poisoned")
1685            .get_mut(project_root)
1686        {
1687            seam.worker_calls += 1;
1688        }
1689    }
1690}
1691
1692fn note_refresh_worker_call_for_test(project_root: &Path) {
1693    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1694        if let Some(seam) = seams
1695            .lock()
1696            .expect("callgraph refresh test seam mutex poisoned")
1697            .get_mut(project_root)
1698        {
1699            seam.refresh_calls += 1;
1700        }
1701    }
1702}
1703
1704fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1705    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1706        if let Some(seam) = seams
1707            .lock()
1708            .expect("callgraph refresh test seam mutex poisoned")
1709            .get_mut(project_root)
1710        {
1711            seam.stale_marks += 1;
1712        }
1713    }
1714}
1715
1716#[doc(hidden)]
1717pub fn set_callgraph_refresh_worker_test_seam(
1718    project_root: PathBuf,
1719    delay: Duration,
1720    fail_refresh: bool,
1721) {
1722    REFRESH_WORKER_TEST_SEAMS
1723        .get_or_init(|| Mutex::new(HashMap::new()))
1724        .lock()
1725        .expect("callgraph refresh test seam mutex poisoned")
1726        .insert(
1727            project_root,
1728            RefreshWorkerTestSeam {
1729                delay,
1730                fail_refresh,
1731                ..RefreshWorkerTestSeam::default()
1732            },
1733        );
1734}
1735
1736#[doc(hidden)]
1737pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1738    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1739        if let Some(seam) = seams
1740            .lock()
1741            .expect("callgraph refresh test seam mutex poisoned")
1742            .get_mut(&project_root)
1743        {
1744            seam.fail_open = enabled;
1745        }
1746    }
1747}
1748
1749#[doc(hidden)]
1750pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1751    let seam = refresh_worker_test_seam(project_root);
1752    (seam.refresh_calls, seam.stale_marks)
1753}
1754
1755#[doc(hidden)]
1756pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1757    refresh_worker_test_seam(project_root).worker_calls
1758}
1759
1760#[doc(hidden)]
1761pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1762    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1763        seams
1764            .lock()
1765            .expect("callgraph refresh test seam mutex poisoned")
1766            .remove(project_root);
1767    }
1768}
1769
1770#[derive(Debug)]
1771pub struct CallGraphStore {
1772    project_root: PathBuf,
1773    project_key: String,
1774    /// The concrete on-disk DB file this store opened. With the generation
1775    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1776    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1777    sqlite_path: PathBuf,
1778    /// Root-keyed directory whose pointer controls this store. For a legacy
1779    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1780    /// newly published root-keyed generation invalidates the fallback reader.
1781    publication_dir: PathBuf,
1782    /// True only when the root-keyed read path opened data from a legacy
1783    /// harness partition. Writer-capable callers use this to schedule migration
1784    /// without making read-only/worktree callers acquire a writer lease.
1785    legacy_fallback: bool,
1786    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1787    /// or `None` when it opened the legacy single-file DB. Used to detect when
1788    /// another process has published a newer generation so this process can
1789    /// drop its connection and reopen (see `current_generation`).
1790    generation: Option<String>,
1791    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1792    read_marker: Option<crate::root_cache::ReadMarker>,
1793    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1794    // Failed validations are not cached, so a later successful build remains visible.
1795    database_ready: AtomicBool,
1796    write_metrics: Arc<CallgraphWriteMetrics>,
1797    conn: Mutex<Connection>,
1798}
1799
1800#[derive(Debug)]
1801pub struct ReadonlyCallGraphStore {
1802    inner: CallGraphStore,
1803}
1804
1805pub trait CallGraphRead {
1806    fn project_root(&self) -> &Path;
1807    fn project_key(&self) -> &str;
1808    fn sqlite_path(&self) -> &Path;
1809    fn is_current(&self) -> bool;
1810    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1811    fn indexed_file_count(&self) -> Result<usize>;
1812    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1813    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1814    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1815    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1816    fn direct_callers_for_symbols(
1817        &self,
1818        targets: &[(String, String)],
1819    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
1820        targets
1821            .iter()
1822            .cloned()
1823            .map(|target| {
1824                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
1825                Ok((target, callers))
1826            })
1827            .collect()
1828    }
1829    fn direct_caller_counts_of(
1830        &self,
1831        targets: &[(String, String)],
1832    ) -> Result<HashMap<(String, String), usize>>;
1833    fn outgoing_calls_for_symbols(
1834        &self,
1835        sources: &[(String, String)],
1836    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
1837    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
1838        -> Result<StoreCallersResult>;
1839    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
1840    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1841    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
1842    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
1843    fn call_tree(
1844        &self,
1845        file_rel: &Path,
1846        symbol: &str,
1847        depth: usize,
1848    ) -> Result<callgraph::CallTreeNode>;
1849    fn trace_to(
1850        &self,
1851        file_rel: &Path,
1852        symbol: &str,
1853        max_depth: usize,
1854    ) -> Result<callgraph::TraceToResult>;
1855    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
1856    fn trace_to_symbol(
1857        &self,
1858        file_rel: &Path,
1859        symbol: &str,
1860        to_symbol: &str,
1861        to_file: Option<&Path>,
1862        max_depth: usize,
1863    ) -> Result<callgraph::TraceToSymbolResult>;
1864}
1865
1866#[derive(Debug, Clone, PartialEq, Eq)]
1867enum OpenRootRepair {
1868    None,
1869    ReRooted,
1870    NeedsRebuild {
1871        previous_roots: Vec<String>,
1872        current_root: String,
1873        reason: String,
1874    },
1875}
1876
1877struct OpenedStore {
1878    store: CallGraphStore,
1879    root_repair: OpenRootRepair,
1880}
1881
1882#[derive(Clone, Debug)]
1883struct LegacyCallgraphPartition {
1884    harness: String,
1885    dir: PathBuf,
1886    key: String,
1887    bytes: u64,
1888    freshness: Option<SystemTime>,
1889}
1890
1891#[derive(Clone, Debug)]
1892struct LegacyCallgraphTarget {
1893    partition: LegacyCallgraphPartition,
1894    sqlite_path: PathBuf,
1895    generation: Option<String>,
1896    source_bytes: u64,
1897    source_blake3: String,
1898}
1899
1900#[derive(Clone, Debug)]
1901struct SourceFingerprint {
1902    bytes: u64,
1903    blake3: String,
1904}
1905
1906#[derive(Clone, Debug)]
1907struct PublishedLegacyMigration {
1908    generation: String,
1909    migrated_bytes: u64,
1910}
1911
1912#[derive(Debug, Clone)]
1913pub struct ColdBuildStats {
1914    pub files: usize,
1915    pub nodes: usize,
1916    pub refs: usize,
1917    pub edges: usize,
1918    pub failed_files: Vec<String>,
1919    pub elapsed_ms: u128,
1920}
1921
1922#[derive(Debug, Clone)]
1923pub struct IncrementalStats {
1924    pub changed_files: Vec<String>,
1925    pub surface_changed: Vec<String>,
1926    pub deleted_files: Vec<String>,
1927    pub dependency_selected_refs: usize,
1928    pub refreshed_own_files: usize,
1929    pub unchanged_extract_files: usize,
1930}
1931
1932/// Phase timings for the copy-based incremental refresh benchmark.
1933#[doc(hidden)]
1934#[derive(Debug, Clone, Default, PartialEq, Eq)]
1935pub struct RefreshFilesProfile {
1936    pub parse: Duration,
1937    pub dependency_selection: Duration,
1938    pub row_deletes: Duration,
1939    pub row_inserts: Duration,
1940    pub dependent_parse: Duration,
1941    pub index_load: Duration,
1942    pub ref_resolution: Duration,
1943    pub method_dispatch: Duration,
1944    pub commit: Duration,
1945    pub total: Duration,
1946}
1947
1948impl RefreshFilesProfile {
1949    pub fn report(&self) -> String {
1950        format!(
1951            "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",
1952            self.parse.as_millis(),
1953            self.dependency_selection.as_millis(),
1954            self.row_deletes.as_millis(),
1955            self.row_inserts.as_millis(),
1956            self.dependent_parse.as_millis(),
1957            self.index_load.as_millis(),
1958            self.ref_resolution.as_millis(),
1959            self.method_dispatch.as_millis(),
1960            self.commit.as_millis(),
1961            self.total.as_millis(),
1962        )
1963    }
1964}
1965
1966#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1967pub struct StoredEdge {
1968    pub source_file: String,
1969    pub source_symbol: String,
1970    pub target_file: String,
1971    pub target_symbol: String,
1972    pub kind: String,
1973    pub line: u32,
1974}
1975
1976#[derive(Debug, Clone, PartialEq, Eq)]
1977pub struct StoreNode {
1978    node_id: String,
1979    pub file: String,
1980    pub symbol: String,
1981    pub name: String,
1982    pub kind: String,
1983    pub line: u32,
1984    pub end_line: u32,
1985    pub signature: Option<String>,
1986    pub exported: bool,
1987    pub is_entry_point: bool,
1988    pub lang: LangId,
1989}
1990
1991#[cfg(test)]
1992impl StoreNode {
1993    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
1994        Self {
1995            node_id: format!("{file}:{symbol}"),
1996            file: file.to_string(),
1997            symbol: symbol.to_string(),
1998            name: symbol.to_string(),
1999            kind: "function".to_string(),
2000            line: 1,
2001            end_line: 1,
2002            signature: None,
2003            exported: is_entry_point,
2004            is_entry_point,
2005            lang: LangId::TypeScript,
2006        }
2007    }
2008}
2009
2010#[derive(Debug, Clone, PartialEq, Eq)]
2011pub struct StoreCallSite {
2012    pub caller: StoreNode,
2013    pub target_file: String,
2014    pub target_symbol: String,
2015    pub target: Option<StoreNode>,
2016    pub line: u32,
2017    pub byte_start: usize,
2018    pub byte_end: usize,
2019    pub resolved: bool,
2020    pub provenance: String,
2021}
2022
2023impl StoreCallSite {
2024    pub fn approximate(&self) -> bool {
2025        self.provenance == PROVENANCE_NAME_MATCH
2026    }
2027
2028    pub fn resolved_by(&self) -> &str {
2029        &self.provenance
2030    }
2031
2032    pub fn supplemental_resolution(&self) -> Option<&str> {
2033        match self.provenance.as_str() {
2034            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2035            _ => None,
2036        }
2037    }
2038}
2039
2040#[derive(Debug, Clone, PartialEq, Eq)]
2041pub struct StoreUnresolvedCall {
2042    pub caller: StoreNode,
2043    pub symbol: String,
2044    pub full_ref: Option<String>,
2045    pub line: u32,
2046    pub byte_start: usize,
2047    pub byte_end: usize,
2048}
2049
2050#[derive(Debug, Clone, PartialEq, Eq)]
2051pub struct StoreCallersResult {
2052    pub target: StoreNode,
2053    pub callers: Vec<StoreCallSite>,
2054    pub scanned_files: usize,
2055    pub depth_limited: bool,
2056    pub truncated: usize,
2057}
2058
2059#[derive(Debug, Clone, PartialEq, Eq)]
2060pub struct StoreImpactCaller {
2061    pub site: StoreCallSite,
2062    pub signature: Option<String>,
2063    pub is_entry_point: bool,
2064    pub call_expression: Option<String>,
2065    pub parameters: Vec<String>,
2066}
2067
2068#[derive(Debug, Clone, PartialEq, Eq)]
2069pub struct StoreImpactResult {
2070    pub target: StoreNode,
2071    pub parameters: Vec<String>,
2072    pub callers: Vec<StoreImpactCaller>,
2073    pub depth_limited: bool,
2074    pub truncated: usize,
2075}
2076
2077#[derive(Debug, Clone)]
2078struct ExtractFailure {
2079    rel_path: String,
2080    freshness: Option<FileFreshness>,
2081}
2082
2083#[derive(Debug, Clone)]
2084struct BuildExtractsResult {
2085    extracts: Vec<FileExtract>,
2086    failures: Vec<ExtractFailure>,
2087}
2088
2089#[derive(Debug, Clone)]
2090enum StoreForwardCall {
2091    Resolved(StoreCallSite),
2092    Unresolved(StoreUnresolvedCall),
2093}
2094
2095impl StoreForwardCall {
2096    fn byte_start(&self) -> usize {
2097        match self {
2098            Self::Resolved(site) => site.byte_start,
2099            Self::Unresolved(call) => call.byte_start,
2100        }
2101    }
2102
2103    fn line(&self) -> u32 {
2104        match self {
2105            Self::Resolved(site) => site.line,
2106            Self::Unresolved(call) => call.line,
2107        }
2108    }
2109}
2110
2111#[derive(Debug, Clone)]
2112struct FileExtract {
2113    rel_path: String,
2114    freshness: FileFreshness,
2115    lang: LangId,
2116    data: FileCallData,
2117    nodes: Vec<NodeRecord>,
2118    raw_refs: Vec<RawRef>,
2119    dispatch_hints: Vec<DispatchHint>,
2120    surface_fingerprint: String,
2121}
2122
2123#[derive(Debug, Clone)]
2124struct NodeRecord {
2125    id: String,
2126    file_path: String,
2127    name: String,
2128    scoped_name: String,
2129    kind: String,
2130    range: Range,
2131    range_ordinal: u32,
2132    signature: Option<String>,
2133    exported: bool,
2134    is_default_export: bool,
2135    is_type_like: bool,
2136    is_callgraph_entry_point: bool,
2137}
2138
2139#[derive(Debug, Clone)]
2140struct RawRef {
2141    ref_id: String,
2142    caller_node: Option<String>,
2143    caller_symbol: Option<String>,
2144    caller_file: String,
2145    kind: String,
2146    short_name: Option<String>,
2147    full_ref: Option<String>,
2148    module_path: Option<String>,
2149    import_kind: Option<String>,
2150    local_name: Option<String>,
2151    requested_name: Option<String>,
2152    namespace_alias: Option<String>,
2153    wildcard: bool,
2154    line: u32,
2155    byte_start: usize,
2156    byte_end: usize,
2157    dependencies: BTreeSet<String>,
2158}
2159
2160/// A raw reference read from the durable staging table with its SQLite ordering
2161/// key. The ordering key is advanced only in the same transaction that writes
2162/// the resolved result, so a crash resumes at a committed window boundary.
2163#[derive(Debug)]
2164struct StagedRef {
2165    rowid: u64,
2166    raw: RawRef,
2167}
2168
2169#[derive(Debug, Clone)]
2170struct ResolvedRef {
2171    raw: RawRef,
2172    status: String,
2173    target_node: Option<String>,
2174    target_file: Option<String>,
2175    target_symbol: Option<String>,
2176    dependencies: BTreeSet<String>,
2177    edge: Option<EdgeRecord>,
2178}
2179
2180#[derive(Debug, Clone)]
2181struct EdgeRecord {
2182    edge_id: String,
2183    source_node: String,
2184    target_node: Option<String>,
2185    target_file: String,
2186    target_symbol: String,
2187    kind: String,
2188    line: u32,
2189}
2190
2191#[derive(Debug, Clone)]
2192struct DispatchHint {
2193    id: String,
2194    method_name: String,
2195    caller_node: String,
2196    file: String,
2197    line: u32,
2198    byte_start: usize,
2199    byte_end: usize,
2200}
2201
2202#[derive(Debug, Clone)]
2203struct NameMatchRef {
2204    ref_id: String,
2205    caller_node: String,
2206    caller_file: String,
2207    caller_symbol: String,
2208    caller_signature: Option<String>,
2209    receiver_expression: String,
2210    receiver: String,
2211    method_name: String,
2212    colon_dispatch: bool,
2213    line: u32,
2214    lang: String,
2215}
2216
2217#[derive(Debug, Clone)]
2218struct NameMatchCandidate {
2219    node_id: String,
2220    file_path: String,
2221    scoped_name: String,
2222    kind: String,
2223    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2224    start_line: u32,
2225}
2226
2227#[derive(Debug, Clone)]
2228struct FileRow {
2229    surface_fingerprint: String,
2230    freshness: FileFreshness,
2231}
2232
2233#[derive(Debug, Clone)]
2234struct DbFileIndex {
2235    lang: Option<LangId>,
2236    exports: HashSet<String>,
2237    default_export: Option<String>,
2238    export_aliases: HashMap<String, String>,
2239    node_by_scoped: HashMap<String, String>,
2240    node_by_bare: HashMap<String, String>,
2241    node_kind_by_id: HashMap<String, String>,
2242    module_targets: HashMap<String, Option<String>>,
2243    declared_module_targets: HashMap<String, Option<String>>,
2244    reexports: Vec<ReexportIndex>,
2245}
2246
2247#[derive(Debug, Clone)]
2248struct ReexportIndex {
2249    target_file: Option<String>,
2250    named: HashMap<String, String>,
2251    wildcard: bool,
2252}
2253
2254#[derive(Debug, Clone)]
2255struct ProjectIndex<'a> {
2256    project_root: PathBuf,
2257    files: HashMap<String, DbFileIndex>,
2258    caller_data: HashMap<String, &'a FileCallData>,
2259    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2260    /// watcher events replace the cache before another batch can resolve refs.
2261    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2262    /// its own workspace mapping.
2263    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2264}
2265
2266/// Resolution reads symbols and exports through one interface. Incremental
2267/// refreshes use the in-memory index, while cold builds query only the rows
2268/// needed by the active caller from SQLite.
2269trait ResolverIndex {
2270    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2271    fn lang_for(&self, file: &str) -> Option<LangId>;
2272    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2273    fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2274    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2275    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2276    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2277    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2278    fn has_export(&self, file: &str, symbol: &str) -> bool;
2279    fn default_export(&self, file: &str) -> Option<String>;
2280    fn contains_file(&self, file: &str) -> bool;
2281    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2282    fn inline_scoped_target(
2283        &self,
2284        caller_file: &str,
2285        module_segments: &[String],
2286        short_name: &str,
2287    ) -> Option<(String, String)>;
2288}
2289
2290impl ResolverIndex for ProjectIndex<'_> {
2291    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2292        self.caller_data.get(file).copied()
2293    }
2294
2295    fn lang_for(&self, file: &str) -> Option<LangId> {
2296        self.lang_for(file)
2297    }
2298
2299    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2300        self.module_target(caller_file, module_path)
2301    }
2302
2303    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2304        let mut parents = self
2305            .files
2306            .iter()
2307            .flat_map(|(file, index)| {
2308                index
2309                    .declared_module_targets
2310                    .iter()
2311                    .filter_map(move |(module, target)| {
2312                        (target.as_deref() == Some(target_file))
2313                            .then(|| (file.clone(), module.clone()))
2314                    })
2315            })
2316            .collect::<Vec<_>>();
2317        parents.sort();
2318        parents.into_iter().next()
2319    }
2320
2321    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2322        self.reexports_for(file).to_vec()
2323    }
2324
2325    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2326        self.node_for_symbol(file, symbol)
2327    }
2328
2329    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2330        self.node_is_callable(file, node_id)
2331    }
2332
2333    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2334        self.files
2335            .get(file)
2336            .and_then(|item| item.export_aliases.get(symbol))
2337            .cloned()
2338    }
2339
2340    fn has_export(&self, file: &str, symbol: &str) -> bool {
2341        self.files
2342            .get(file)
2343            .is_some_and(|item| item.exports.contains(symbol))
2344    }
2345
2346    fn default_export(&self, file: &str) -> Option<String> {
2347        self.files
2348            .get(file)
2349            .and_then(|item| item.default_export.clone())
2350    }
2351
2352    fn contains_file(&self, file: &str) -> bool {
2353        self.files.contains_key(file)
2354    }
2355
2356    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2357        self.workspace_crate_prefixes
2358            .0
2359            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
2360            .get(crate_name)
2361            .cloned()
2362    }
2363
2364    fn inline_scoped_target(
2365        &self,
2366        caller_file: &str,
2367        module_segments: &[String],
2368        short_name: &str,
2369    ) -> Option<(String, String)> {
2370        let src_prefix = rust_src_prefix(caller_file);
2371        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2372        file_paths.sort();
2373        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2374            let caller = file_paths.remove(position);
2375            file_paths.insert(0, caller);
2376        }
2377        for file_path in file_paths {
2378            if self.lang_for(&file_path) != Some(LangId::Rust)
2379                || rust_src_prefix(&file_path) != src_prefix
2380            {
2381                continue;
2382            }
2383            let file_module_segments = rust_module_segments_for_rel(&file_path);
2384            if !module_segments.starts_with(&file_module_segments) {
2385                continue;
2386            }
2387            let scoped_segments = &module_segments[file_module_segments.len()..];
2388            if scoped_segments.is_empty() {
2389                continue;
2390            }
2391            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2392            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2393                return Some((file_path, scoped_symbol));
2394            }
2395        }
2396        None
2397    }
2398}
2399
2400/// A cold-build resolver view that loads one file's index at a time. Keeping the
2401/// complete staged corpus in SQLite makes the heap proportional to the active
2402/// reference window rather than to the number of project files.
2403struct DiskProjectIndex<'a> {
2404    project_root: &'a Path,
2405    conn: &'a Connection,
2406    caller_file: &'a str,
2407    caller_data: &'a FileCallData,
2408    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2409    module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2410}
2411
2412impl DiskProjectIndex<'_> {
2413    fn file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2414        let lang: String = self
2415            .conn
2416            .query_row(
2417                "SELECT lang FROM files WHERE path = ?1",
2418                params![rel_path],
2419                |row| row.get(0),
2420            )
2421            .optional()
2422            .ok()??;
2423        let mut index = DbFileIndex {
2424            lang: lang_from_label(&lang),
2425            exports: HashSet::new(),
2426            default_export: None,
2427            export_aliases: HashMap::new(),
2428            node_by_scoped: HashMap::new(),
2429            node_by_bare: HashMap::new(),
2430            node_kind_by_id: HashMap::new(),
2431            module_targets: HashMap::new(),
2432            declared_module_targets: HashMap::new(),
2433            reexports: Vec::new(),
2434        };
2435        let mut nodes = self
2436            .conn
2437            .prepare(
2438                "SELECT id, name, scoped_name, kind, exported, is_default_export
2439                 FROM nodes WHERE file_path = ?1",
2440            )
2441            .ok()?;
2442        let rows = nodes
2443            .query_map(params![rel_path], |row| {
2444                Ok((
2445                    row.get::<_, String>(0)?,
2446                    row.get::<_, String>(1)?,
2447                    row.get::<_, String>(2)?,
2448                    row.get::<_, String>(3)?,
2449                    row.get::<_, i64>(4)? != 0,
2450                    row.get::<_, i64>(5)? != 0,
2451                ))
2452            })
2453            .ok()?
2454            .collect::<std::result::Result<Vec<_>, _>>()
2455            .ok()?;
2456        drop(nodes);
2457        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2458            if exported {
2459                index.exports.insert(name.clone());
2460                index.exports.insert(scoped_name.clone());
2461            }
2462            if is_default_export {
2463                index.default_export = Some(scoped_name.clone());
2464            }
2465            index.node_by_scoped.insert(scoped_name, id.clone());
2466            index.node_by_bare.entry(name).or_insert(id.clone());
2467            index.node_kind_by_id.insert(id, kind);
2468        }
2469
2470        let mut refs = self
2471            .conn
2472            .prepare(
2473                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2474                  FROM refs
2475                  WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2476            )
2477            .ok()?;
2478        let rows = refs
2479            .query_map(params![rel_path], |row| {
2480                Ok((
2481                    row.get::<_, String>(0)?,
2482                    row.get::<_, String>(1)?,
2483                    row.get::<_, Option<String>>(2)?,
2484                    row.get::<_, Option<String>>(3)?,
2485                    row.get::<_, i64>(4)? != 0,
2486                    row.get::<_, Option<String>>(5)?,
2487                    row.get::<_, Option<String>>(6)?,
2488                ))
2489            })
2490            .ok()?
2491            .collect::<std::result::Result<Vec<_>, _>>()
2492            .ok()?;
2493        drop(refs);
2494        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2495            if kind == "export_alias" {
2496                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2497                    index.export_aliases.insert(exported, source);
2498                }
2499                continue;
2500            }
2501            let Some(module_path) = module_path else {
2502                continue;
2503            };
2504            let target_file = if kind == "module" {
2505                rust_declared_module_target(
2506                    self.project_root,
2507                    rel_path,
2508                    &module_path,
2509                    self.module_resolution_memo,
2510                )
2511            } else {
2512                self.disk_module_target(rel_path, &module_path)
2513            }
2514            .or_else(|| {
2515                self.conn
2516                    .query_row(
2517                        "SELECT d.dep_file
2518                         FROM file_dependencies d
2519                         JOIN files f ON f.path = d.dep_file
2520                         WHERE d.file_path = ?1
2521                         ORDER BY d.dep_file
2522                         LIMIT 1",
2523                        params![rel_path],
2524                        |row| row.get::<_, String>(0),
2525                    )
2526                    .optional()
2527                    .ok()
2528                    .flatten()
2529            });
2530            index
2531                .module_targets
2532                .entry(module_path.clone())
2533                .or_insert_with(|| target_file.clone());
2534            if kind == "module" {
2535                index
2536                    .declared_module_targets
2537                    .entry(module_path.clone())
2538                    .or_insert_with(|| target_file.clone());
2539            }
2540            if kind == "reexport" {
2541                let raw = RawRef {
2542                    ref_id,
2543                    caller_node: None,
2544                    caller_symbol: None,
2545                    caller_file: rel_path.to_string(),
2546                    kind,
2547                    short_name: None,
2548                    full_ref,
2549                    module_path: Some(module_path),
2550                    import_kind: Some("reexport".to_string()),
2551                    local_name: None,
2552                    requested_name: None,
2553                    namespace_alias: None,
2554                    wildcard,
2555                    line: 0,
2556                    byte_start: 0,
2557                    byte_end: 0,
2558                    dependencies: BTreeSet::new(),
2559                };
2560                index
2561                    .reexports
2562                    .push(reexport_index_from_raw(&raw, target_file));
2563            }
2564        }
2565        Some(index)
2566    }
2567
2568    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2569        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2570        let candidate = callgraph::resolve_module_path_with_memo(
2571            &caller_dir,
2572            module_path,
2573            self.module_resolution_memo,
2574        )?;
2575        let rel_path = relative_path(self.project_root, &candidate);
2576        self.contains_file(&rel_path).then_some(rel_path)
2577    }
2578}
2579
2580impl ResolverIndex for DiskProjectIndex<'_> {
2581    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2582        (file == self.caller_file).then_some(self.caller_data)
2583    }
2584
2585    fn lang_for(&self, file: &str) -> Option<LangId> {
2586        self.file_index(file).and_then(|index| index.lang)
2587    }
2588
2589    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2590        self.file_index(caller_file)
2591            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2592    }
2593
2594    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2595        let mut stmt = self
2596            .conn
2597            .prepare(
2598                "SELECT caller_file, module_path FROM refs
2599                 WHERE kind = 'module' AND module_path IS NOT NULL
2600                 ORDER BY caller_file, module_path",
2601            )
2602            .ok()?;
2603        let rows = stmt
2604            .query_map([], |row| {
2605                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2606            })
2607            .ok()?;
2608        for row in rows.flatten() {
2609            if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2610                return Some(row);
2611            }
2612        }
2613        None
2614    }
2615
2616    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2617        self.file_index(file)
2618            .map(|index| index.reexports)
2619            .unwrap_or_default()
2620    }
2621
2622    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2623        self.file_index(file).and_then(|index| {
2624            index
2625                .node_by_scoped
2626                .get(symbol)
2627                .cloned()
2628                .or_else(|| index.node_by_bare.get(symbol).cloned())
2629        })
2630    }
2631
2632    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2633        self.file_index(file)
2634            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2635            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
2636    }
2637
2638    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2639        self.file_index(file)
2640            .and_then(|index| index.export_aliases.get(symbol).cloned())
2641    }
2642
2643    fn has_export(&self, file: &str, symbol: &str) -> bool {
2644        self.file_index(file)
2645            .is_some_and(|index| index.exports.contains(symbol))
2646    }
2647
2648    fn default_export(&self, file: &str) -> Option<String> {
2649        self.file_index(file).and_then(|index| index.default_export)
2650    }
2651
2652    fn contains_file(&self, file: &str) -> bool {
2653        self.conn
2654            .query_row(
2655                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2656                params![file],
2657                |_| Ok(()),
2658            )
2659            .is_ok()
2660    }
2661
2662    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2663        self.workspace_crate_prefixes
2664            .0
2665            .get_or_init(|| build_workspace_crate_prefixes(self.project_root))
2666            .get(crate_name)
2667            .cloned()
2668    }
2669
2670    fn inline_scoped_target(
2671        &self,
2672        caller_file: &str,
2673        module_segments: &[String],
2674        short_name: &str,
2675    ) -> Option<(String, String)> {
2676        let src_prefix = rust_src_prefix(caller_file);
2677        let check = |file_path: String| {
2678            let file_module_segments = rust_module_segments_for_rel(&file_path);
2679            if rust_src_prefix(&file_path) != src_prefix
2680                || !module_segments.starts_with(&file_module_segments)
2681            {
2682                return None;
2683            }
2684            let scoped_segments = &module_segments[file_module_segments.len()..];
2685            if scoped_segments.is_empty() {
2686                return None;
2687            }
2688            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2689            self.node_for_symbol(&file_path, &scoped_symbol)
2690                .map(|_| (file_path, scoped_symbol))
2691        };
2692        if let Some(target) = check(caller_file.to_string()) {
2693            return Some(target);
2694        }
2695        let mut statement = self
2696            .conn
2697            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2698            .ok()?;
2699        let rows = statement
2700            .query_map(params![caller_file], |row| row.get::<_, String>(0))
2701            .ok()?;
2702        for path in rows.flatten() {
2703            if let Some(target) = check(path) {
2704                return Some(target);
2705            }
2706        }
2707        None
2708    }
2709}
2710
2711impl CallGraphStore {
2712    pub fn open_if_enabled(
2713        options: CallGraphStoreOptions,
2714        callgraph_dir: PathBuf,
2715        project_root: PathBuf,
2716    ) -> Result<Option<Self>> {
2717        if !options.enabled {
2718            return Ok(None);
2719        }
2720        Self::open(callgraph_dir, project_root).map(Some)
2721    }
2722
2723    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
2724        let project_key = crate::search_index::artifact_cache_key(&project_root);
2725        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2726        else {
2727            return Err(CallGraphStoreError::Unavailable(
2728                "writer capability denied; use the read-only callgraph opener".to_string(),
2729            ));
2730        };
2731        std::fs::create_dir_all(&callgraph_dir)?;
2732        // Resolve the current generation via the pointer (falling back to the
2733        // legacy single-file DB). If nothing is published yet, open the legacy
2734        // path so a brand-new store still gets a writable DB + schema.
2735        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
2736            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
2737        let OpenedStore { store, root_repair } = Self::open_at_path(
2738            project_root.clone(),
2739            project_key,
2740            sqlite_path,
2741            generation,
2742            true,
2743            Some(Arc::clone(&writer_lease)),
2744            None,
2745        )?;
2746        match root_repair {
2747            OpenRootRepair::NeedsRebuild { .. } => {
2748                log_root_repair_rebuild(&root_repair);
2749                drop(store);
2750                drop(writer_lease);
2751                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2752                let (store, _stats) =
2753                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2754                Ok(store)
2755            }
2756            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
2757        }
2758    }
2759
2760    pub fn open_readonly(
2761        callgraph_dir: PathBuf,
2762        project_root: PathBuf,
2763    ) -> Result<Option<ReadonlyCallGraphStore>> {
2764        let project_key = crate::search_index::artifact_cache_key(&project_root);
2765        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2766        {
2767            let conn = open_readonly_connection(&sqlite_path)?;
2768            if !database_ready(&conn).unwrap_or(false) {
2769                return Ok(None);
2770            }
2771            let marker_label = generation.as_deref().unwrap_or("legacy");
2772            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
2773            return Ok(Some(ReadonlyCallGraphStore::from_inner(
2774                Self::from_connection(
2775                    project_root,
2776                    project_key,
2777                    sqlite_path,
2778                    callgraph_dir,
2779                    false,
2780                    generation,
2781                    None,
2782                    Some(read_marker),
2783                    conn,
2784                ),
2785            )));
2786        }
2787
2788        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
2789            return Ok(None);
2790        };
2791        crate::slog_warn!(
2792            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
2793            target.partition.harness,
2794            target.sqlite_path.display()
2795        );
2796        let conn = open_readonly_connection(&target.sqlite_path)?;
2797        if !database_ready(&conn).unwrap_or(false) {
2798            return Ok(None);
2799        }
2800        let marker_label =
2801            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
2802        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
2803        Ok(Some(ReadonlyCallGraphStore::from_inner(
2804            Self::from_connection(
2805                project_root,
2806                project_key,
2807                target.sqlite_path,
2808                callgraph_dir,
2809                true,
2810                target.generation,
2811                None,
2812                Some(read_marker),
2813                conn,
2814            ),
2815        )))
2816    }
2817
2818    /// Open the currently-published ready store with write access so moved-root
2819    /// metadata can be repaired before projection readers consume it. Unlike
2820    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
2821    /// ready generation exists, it returns `Ok(None)` instead of creating an
2822    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
2823    pub fn open_ready_repairing(
2824        callgraph_dir: PathBuf,
2825        project_root: PathBuf,
2826    ) -> Result<Option<Self>> {
2827        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
2828    }
2829
2830    /// Open a ready store for bounded maintenance work without repairing root
2831    /// metadata or starting a cold rebuild. A store that needs either action is
2832    /// reported as unavailable so a background build can own that work.
2833    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
2834        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
2835    }
2836
2837    pub fn open_ready_no_rebuild(
2838        callgraph_dir: PathBuf,
2839        project_root: PathBuf,
2840    ) -> Result<Option<Self>> {
2841        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
2842    }
2843
2844    fn open_ready_with_rebuild_policy(
2845        callgraph_dir: PathBuf,
2846        project_root: PathBuf,
2847        allow_cold_build: bool,
2848        allow_root_repair: bool,
2849    ) -> Result<Option<Self>> {
2850        let project_key = crate::search_index::artifact_cache_key(&project_root);
2851        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2852        else {
2853            return Ok(None);
2854        };
2855        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2856        else {
2857            return Ok(None);
2858        };
2859        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
2860            project_root.clone(),
2861            project_key.clone(),
2862            sqlite_path,
2863            generation,
2864            true,
2865            Some(Arc::clone(&writer_lease)),
2866            None,
2867            allow_root_repair,
2868        )?;
2869        match root_repair {
2870            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
2871                log_root_repair_rebuild(&root_repair);
2872                drop(store);
2873                drop(writer_lease);
2874                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2875                let (store, _stats) =
2876                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
2877                Ok(Some(store))
2878            }
2879            OpenRootRepair::NeedsRebuild { .. } => {
2880                if let Some(message) = note_repair_entry(&project_key) {
2881                    crate::slog_warn!("{message}");
2882                }
2883                Ok(None)
2884            }
2885            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
2886        }
2887    }
2888
2889    pub fn cold_build_with_lease(
2890        callgraph_dir: PathBuf,
2891        project_root: PathBuf,
2892        files: &[PathBuf],
2893    ) -> Result<(Self, ColdBuildStats)> {
2894        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
2895    }
2896
2897    pub fn cold_build_with_lease_chunked(
2898        callgraph_dir: PathBuf,
2899        project_root: PathBuf,
2900        files: &[PathBuf],
2901        chunk_size: usize,
2902    ) -> Result<(Self, ColdBuildStats)> {
2903        Self::cold_build_with_lease_chunked_inner(
2904            callgraph_dir,
2905            project_root,
2906            files,
2907            chunk_size,
2908            false,
2909        )
2910    }
2911
2912    pub(crate) fn force_cold_build_with_lease_chunked(
2913        callgraph_dir: PathBuf,
2914        project_root: PathBuf,
2915        files: &[PathBuf],
2916        chunk_size: usize,
2917    ) -> Result<(Self, ColdBuildStats)> {
2918        Self::cold_build_with_lease_chunked_inner(
2919            callgraph_dir,
2920            project_root,
2921            files,
2922            chunk_size,
2923            true,
2924        )
2925    }
2926
2927    fn cold_build_with_lease_chunked_inner(
2928        callgraph_dir: PathBuf,
2929        project_root: PathBuf,
2930        files: &[PathBuf],
2931        chunk_size: usize,
2932        require_new_publication: bool,
2933    ) -> Result<(Self, ColdBuildStats)> {
2934        let project_key = crate::search_index::artifact_cache_key(&project_root);
2935        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2936        else {
2937            let operation = if require_new_publication {
2938                "forced rebuild"
2939            } else {
2940                "cold build"
2941            };
2942            return Err(CallGraphStoreError::Unavailable(format!(
2943                "{operation} could not acquire writer capability"
2944            )));
2945        };
2946        std::fs::create_dir_all(&callgraph_dir)?;
2947        let (stats, generation) = Self::cold_build_publish_locked(
2948            &callgraph_dir,
2949            &project_root,
2950            &project_key,
2951            files,
2952            chunk_size,
2953            Arc::clone(&writer_lease),
2954        )?;
2955        let store = Self::open_generation(
2956            &callgraph_dir,
2957            project_root,
2958            project_key,
2959            generation,
2960            writer_lease,
2961        )?;
2962        Ok((store, stats))
2963    }
2964
2965    pub fn ensure_built_with_lease(
2966        callgraph_dir: PathBuf,
2967        project_root: PathBuf,
2968        files: &[PathBuf],
2969    ) -> Result<(Self, Option<ColdBuildStats>)> {
2970        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
2971    }
2972
2973    pub fn ensure_built_with_lease_chunked(
2974        callgraph_dir: PathBuf,
2975        project_root: PathBuf,
2976        files: &[PathBuf],
2977        chunk_size: usize,
2978    ) -> Result<(Self, Option<ColdBuildStats>)> {
2979        let project_key = crate::search_index::artifact_cache_key(&project_root);
2980        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
2981        else {
2982            return Err(CallGraphStoreError::Unavailable(
2983                "callgraph ensure could not acquire writer capability".to_string(),
2984            ));
2985        };
2986        std::fs::create_dir_all(&callgraph_dir)?;
2987        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
2988        // Another process may have published a ready generation while we waited
2989        // for the lock — open it instead of rebuilding. If that generation is
2990        // from this same project at an older filesystem root, repair the root
2991        // metadata in-place while still holding the build lease. If data rows
2992        // contain absolute paths, publish a fresh generation under this lease
2993        // rather than recursively reacquiring the same lock.
2994        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
2995        {
2996            let OpenedStore { store, root_repair } = Self::open_at_path(
2997                project_root.clone(),
2998                project_key.clone(),
2999                sqlite_path,
3000                generation,
3001                true,
3002                Some(Arc::clone(&writer_lease)),
3003                None,
3004            )?;
3005            match root_repair {
3006                OpenRootRepair::NeedsRebuild { .. } => {
3007                    log_root_repair_rebuild(&root_repair);
3008                    drop(store);
3009                    let (stats, generation) = Self::cold_build_publish_locked(
3010                        &callgraph_dir,
3011                        &project_root,
3012                        &project_key,
3013                        files,
3014                        chunk_size,
3015                        Arc::clone(&writer_lease),
3016                    )?;
3017                    let store = Self::open_generation(
3018                        &callgraph_dir,
3019                        project_root,
3020                        project_key,
3021                        generation,
3022                        writer_lease,
3023                    )?;
3024                    return Ok((store, Some(stats)));
3025                }
3026                OpenRootRepair::None | OpenRootRepair::ReRooted => {
3027                    return Ok((store, None));
3028                }
3029            }
3030        }
3031        if let Some(store) = try_legacy_migration_or_fallback(
3032            &callgraph_dir,
3033            &project_root,
3034            &project_key,
3035            Arc::clone(&writer_lease),
3036        )? {
3037            return Ok((store, None));
3038        }
3039        let (stats, generation) = Self::cold_build_publish_locked(
3040            &callgraph_dir,
3041            &project_root,
3042            &project_key,
3043            files,
3044            chunk_size,
3045            Arc::clone(&writer_lease),
3046        )?;
3047        let store = Self::open_generation(
3048            &callgraph_dir,
3049            project_root,
3050            project_key,
3051            generation,
3052            writer_lease,
3053        )?;
3054        Ok((store, Some(stats)))
3055    }
3056
3057    /// Migrate a legacy harness-partition store without falling through to a
3058    /// cold build. This is used after a query has already opened a read-only
3059    /// fallback: the caller runs it on the same limited background lane as cold
3060    /// builds while queries continue using that fallback. Public so crash/retry
3061    /// tests can drive the migration synchronously on a thread where the
3062    /// thread-local failure seams apply.
3063    pub fn migrate_legacy_with_lease(
3064        callgraph_dir: PathBuf,
3065        project_root: PathBuf,
3066    ) -> Result<Option<Self>> {
3067        let project_key = crate::search_index::artifact_cache_key(&project_root);
3068        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3069        else {
3070            return Ok(None);
3071        };
3072        std::fs::create_dir_all(&callgraph_dir)?;
3073        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3074
3075        // Another writer may have completed the migration while this worker was
3076        // waiting for the lease. Adopt its root-keyed generation rather than
3077        // copying the legacy source a second time.
3078        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3079        {
3080            let OpenedStore { store, root_repair } = Self::open_at_path(
3081                project_root,
3082                project_key,
3083                sqlite_path,
3084                generation,
3085                true,
3086                Some(writer_lease),
3087                None,
3088            )?;
3089            return match root_repair {
3090                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3091                OpenRootRepair::NeedsRebuild { reason, .. } => {
3092                    Err(CallGraphStoreError::Unavailable(format!(
3093                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3094                    )))
3095                }
3096            };
3097        }
3098
3099        let store = try_legacy_migration_or_fallback(
3100            &callgraph_dir,
3101            &project_root,
3102            &project_key,
3103            writer_lease,
3104        )?;
3105        // A disk-floor or backup-budget failure returns a readable legacy store.
3106        // Keep the already-resident fallback instead of sending this duplicate
3107        // reader through the background-install channel.
3108        Ok(store.filter(|store| !store.is_legacy_fallback()))
3109    }
3110
3111    /// Build a fresh DB and publish it as a new generation, then atomically flip
3112    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
3113    /// succeeds even when other processes hold an older generation open (the
3114    /// multi-TUI Windows case). The builder owns the temp + generation files
3115    /// exclusively (unique pid+nanos names), so it can rename/replace them
3116    /// freely; only the tiny pointer is shared, and only Rust std touches it.
3117    ///
3118    /// Returns the published generation file name so callers open exactly the
3119    /// generation they built (avoiding a race where a concurrent build's flip
3120    /// would otherwise reopen a different generation).
3121    fn cold_build_publish_locked(
3122        callgraph_dir: &Path,
3123        project_root: &Path,
3124        project_key: &str,
3125        files: &[PathBuf],
3126        chunk_size: usize,
3127        writer_lease: Arc<crate::root_cache::WriterLease>,
3128    ) -> Result<(ColdBuildStats, String)> {
3129        if let Some((previous_root, remaining)) =
3130            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3131        {
3132            return Err(CallGraphStoreError::Unavailable(format!(
3133                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3134                previous_root.display(),
3135                remaining.as_millis()
3136            )));
3137        }
3138        let breaker = crate::build_breaker::BuildDeathBreaker::open(
3139            callgraph_dir.join("build-breaker.sqlite"),
3140        )
3141        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3142
3143        let generation = generation_file_name(project_key);
3144        let gen_path = callgraph_dir.join(&generation);
3145        // A writer lease makes this root/domain's staging generation exclusive.
3146        // Keep its identity stable so a replacement process adopts committed
3147        // batches instead of minting a second temp and starting from zero.
3148        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3149        let adopting_staging = temp_path.exists();
3150        if !adopting_staging {
3151            remove_sqlite_file_set(&temp_path);
3152        }
3153
3154        let scope = crate::logging::IndexBuildScope::new(
3155            crate::logging::IndexPlane::Callgraph,
3156            project_root,
3157            project_key,
3158        );
3159        let _index_build = crate::logging::install_index_build(scope.clone());
3160        let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
3161        let mut started = crate::logging::IndexEvent::from_scope(
3162            crate::logging::IndexEventKind::BuildStarted,
3163            &scope,
3164        );
3165        if adopting_staging {
3166            started = started.field("resumed_from_staging", "true");
3167        }
3168        crate::logging::log_index_event(started);
3169
3170        let (stats, breaker_key) = {
3171            if adopting_staging {
3172                crate::slog_info!(
3173                    "resuming callgraph cold build from staged generation {}",
3174                    temp_path.display()
3175                );
3176            }
3177            let temp_store = Self::open_at_path(
3178                project_root.to_path_buf(),
3179                project_key.to_string(),
3180                temp_path.clone(),
3181                None,
3182                false,
3183                Some(Arc::clone(&writer_lease)),
3184                None,
3185            )?
3186            .store;
3187            // Admission must precede every expensive build phase and every
3188            // staging write: a suspended root is refused before the process
3189            // spends anything, and a death during enumeration is attributable
3190            // to an admitted attempt. The breaker key needs the corpus
3191            // fingerprint, so that one input is resolved by a standalone
3192            // streaming walk first (sanctioned pre-admission work) - the
3193            // inventory pass below recomputes it while staging; the staged
3194            // value governs resume cursors, while the admission key stays
3195            // pinned to the admitted fingerprint so a file racing the walk
3196            // cannot detach the attempt from its breaker record.
3197            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3198            let breaker_key = crate::build_breaker::BreakerKey::new(
3199                project_root.display().to_string(),
3200                crate::build_breaker::BuildDomain::CallgraphCold,
3201                admission_fingerprint,
3202            );
3203            match breaker
3204                .admit(&breaker_key, 0)
3205                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3206            {
3207                crate::build_breaker::BreakerAdmission::Admitted(_) => {
3208                    crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3209                        crate::logging::IndexEventKind::BreakerAdmitted,
3210                        &scope,
3211                    ));
3212                }
3213                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3214                    crate::logging::log_index_event(
3215                        crate::logging::IndexEvent::from_scope(
3216                            crate::logging::IndexEventKind::BuildSuspended,
3217                            &scope,
3218                        )
3219                        .field("reason", &suspension.reason),
3220                    );
3221                    failure_guard.disarm();
3222                    return Err(CallGraphStoreError::Suspended(suspension));
3223                }
3224            }
3225            ensure_cold_build_current("inventory", 0, 1)?;
3226            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3227            ensure_cold_build_current("inventory", 1, 1)?;
3228            let stats = temp_store
3229                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3230            let _ = temp_store.checkpoint_wal_truncate();
3231            temp_store.prepare_for_atomic_swap()?;
3232            (stats, breaker_key)
3233        };
3234
3235        notify_cold_build_before_publish_observer();
3236        let publication = publish_if_current(|| {
3237            verify_writer_lease(&writer_lease)?;
3238            // Move the finished build to its final generation path. This target is
3239            // brand-new and owned by us, so the rename never hits an open file.
3240            remove_sqlite_file_set(&gen_path);
3241            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3242            crate::fs_lock::sync_parent(&gen_path);
3243            remove_sqlite_sidecars(&gen_path);
3244
3245            notify_cold_build_swap_observer(&temp_path, &gen_path);
3246
3247            // Atomically publish the new generation, then best-effort GC old ones.
3248            verify_writer_lease(&writer_lease)?;
3249            publish_pointer(callgraph_dir, project_key, &generation)?;
3250            gc_old_generations(callgraph_dir, project_key, &generation);
3251            // Store-wide orphan sweep on the same cadence: reclaims aged build
3252            // temps for roots that no longer build here, which the per-root GC
3253            // above never reaches.
3254            sweep_orphaned_build_temps_store_wide(callgraph_dir);
3255            sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3256            crate::search_index::sweep_transient_search_cache_dirs();
3257            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3258                let inspect_root =
3259                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3260                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3261                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3262            }
3263            Ok(())
3264        });
3265        // A superseded generation remains a valid resumable staging artifact.
3266        // Its successor compares the durable corpus fingerprint before either
3267        // adopting this work or resetting it for a changed corpus.
3268        if let Err(CallGraphStoreError::Superseded) = &publication {
3269            crate::logging::log_index_event(
3270                crate::logging::IndexEvent::from_scope(
3271                    crate::logging::IndexEventKind::BuildSuperseded,
3272                    &scope,
3273                )
3274                .field("stage", "publish"),
3275            );
3276            failure_guard.disarm();
3277        }
3278        publication?;
3279        // Pointer publication is the only automatic breaker reset. The staging
3280        // batches above never reset history because a process can die after them.
3281        breaker
3282            .record_ready_publication(&breaker_key)
3283            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3284        crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3285            crate::logging::IndexEventKind::BreakerReset,
3286            &scope,
3287        ));
3288        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3289        crate::logging::log_index_event(
3290            crate::logging::IndexEvent::from_scope(
3291                crate::logging::IndexEventKind::BuildReady,
3292                &scope,
3293            )
3294            .field("elapsed_ms", scope.elapsed_ms())
3295            .field("files", stats.files)
3296            .field("edges", stats.edges),
3297        );
3298        failure_guard.disarm();
3299        Ok((stats, generation))
3300    }
3301
3302    /// Open a specific just-published generation (read-write, WAL) so a builder
3303    /// returns a store pinned to exactly what it built.
3304    fn open_generation(
3305        callgraph_dir: &Path,
3306        project_root: PathBuf,
3307        project_key: String,
3308        generation: String,
3309        writer_lease: Arc<crate::root_cache::WriterLease>,
3310    ) -> Result<Self> {
3311        let gen_path = callgraph_dir.join(&generation);
3312        Ok(Self::open_at_path(
3313            project_root,
3314            project_key,
3315            gen_path,
3316            Some(generation),
3317            true,
3318            Some(writer_lease),
3319            None,
3320        )?
3321        .store)
3322    }
3323
3324    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3325        let project_key = crate::search_index::artifact_cache_key(project_root);
3326        // A cold build is needed unless a ready generation (or ready legacy DB)
3327        // is currently published.
3328        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3329    }
3330
3331    /// Check the durable callgraph-domain breaker before a query starts a cold
3332    /// worker. This only runs while no ready generation exists; it never builds
3333    /// inline and lets a tripped root return a terminal answer instead of an
3334    /// endless `Building` response.
3335    pub fn cold_build_suspension(
3336        callgraph_dir: &Path,
3337        project_root: &Path,
3338    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3339        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3340        if !breaker_path.exists() {
3341            return Ok(None);
3342        }
3343        let key = crate::build_breaker::BreakerKey::new(
3344            project_root.display().to_string(),
3345            crate::build_breaker::BuildDomain::CallgraphCold,
3346            callgraph_corpus_fingerprint(project_root)?,
3347        );
3348        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3349            .and_then(|breaker| breaker.suspension(&key))
3350            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3351    }
3352
3353    fn open_at_path(
3354        project_root: PathBuf,
3355        project_key: String,
3356        sqlite_path: PathBuf,
3357        generation: Option<String>,
3358        use_wal: bool,
3359        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3360        read_marker: Option<crate::root_cache::ReadMarker>,
3361    ) -> Result<OpenedStore> {
3362        Self::open_at_path_with_root_repair(
3363            project_root,
3364            project_key,
3365            sqlite_path,
3366            generation,
3367            use_wal,
3368            writer_lease,
3369            read_marker,
3370            true,
3371        )
3372    }
3373
3374    fn open_at_path_with_root_repair(
3375        project_root: PathBuf,
3376        project_key: String,
3377        sqlite_path: PathBuf,
3378        generation: Option<String>,
3379        use_wal: bool,
3380        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3381        read_marker: Option<crate::root_cache::ReadMarker>,
3382        allow_root_repair: bool,
3383    ) -> Result<OpenedStore> {
3384        if let Some(lease) = writer_lease.as_ref() {
3385            verify_writer_lease(lease)?;
3386        }
3387        if let Some(parent) = sqlite_path.parent() {
3388            std::fs::create_dir_all(parent)?;
3389        }
3390        let mut conn = Connection::open(&sqlite_path)?;
3391        if use_wal {
3392            configure_connection(&conn)?;
3393        } else {
3394            configure_build_connection(&conn)?;
3395        }
3396        if let Some(lease) = writer_lease.as_ref() {
3397            verify_writer_lease(lease)?;
3398        }
3399        initialize_schema(&conn)?;
3400        if let Some(lease) = writer_lease.as_ref() {
3401            verify_writer_lease(lease)?;
3402        }
3403        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3404        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3405            (Some(marker), _, _) => Some(marker),
3406            (None, Some(label), Some(cache_dir)) => {
3407                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3408            }
3409            (None, _, _) => None,
3410        };
3411        let publication_dir = sqlite_path
3412            .parent()
3413            .map(Path::to_path_buf)
3414            .unwrap_or_default();
3415        let store = Self::from_connection(
3416            project_root,
3417            project_key,
3418            sqlite_path,
3419            publication_dir,
3420            false,
3421            generation,
3422            writer_lease,
3423            read_marker,
3424            conn,
3425        );
3426        Ok(OpenedStore { store, root_repair })
3427    }
3428
3429    fn prepare_for_atomic_swap(&self) -> Result<()> {
3430        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3431        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3432        Ok(())
3433    }
3434
3435    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3436        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3437            self.sqlite_path
3438                .parent()
3439                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3440        });
3441        if protected_reader {
3442            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3443        } else {
3444            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3445        }
3446    }
3447
3448    fn from_connection(
3449        project_root: PathBuf,
3450        project_key: String,
3451        sqlite_path: PathBuf,
3452        publication_dir: PathBuf,
3453        legacy_fallback: bool,
3454        generation: Option<String>,
3455        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3456        read_marker: Option<crate::root_cache::ReadMarker>,
3457        conn: Connection,
3458    ) -> Self {
3459        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3460        Self {
3461            project_root,
3462            project_key,
3463            sqlite_path,
3464            publication_dir,
3465            legacy_fallback,
3466            generation,
3467            writer_lease,
3468            read_marker,
3469            database_ready: AtomicBool::new(false),
3470            write_metrics,
3471            conn: Mutex::new(conn),
3472        }
3473    }
3474
3475    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3476        if self.database_ready.load(AtomicOrdering::Acquire) {
3477            return Ok(());
3478        }
3479        ensure_database_ready(conn)?;
3480        self.database_ready.store(true, AtomicOrdering::Release);
3481        Ok(())
3482    }
3483
3484    pub fn project_root(&self) -> &Path {
3485        &self.project_root
3486    }
3487
3488    pub fn project_key(&self) -> &str {
3489        &self.project_key
3490    }
3491
3492    pub fn sqlite_path(&self) -> &Path {
3493        &self.sqlite_path
3494    }
3495
3496    /// The generation file named by the publication pointer when this store opened.
3497    pub(crate) fn projection_generation(&self) -> Option<&str> {
3498        self.generation.as_deref()
3499    }
3500
3501    /// Read the durable revision that changes in the same transaction as graph writes.
3502    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3503        self.refresh_read_marker()?;
3504        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3505        self.ensure_ready(&conn)?;
3506        projection_write_revision(&conn)
3507    }
3508
3509    /// Whether this store is reading from a legacy harness partition because
3510    /// the root-keyed store has not published a generation yet.
3511    pub fn is_legacy_fallback(&self) -> bool {
3512        self.legacy_fallback
3513    }
3514
3515    pub(crate) fn is_legacy_migration(&self) -> bool {
3516        self.generation.as_deref().is_some_and(|generation| {
3517            migration_generation_requires_manifest(generation)
3518                && migration_manifest_valid(&self.publication_dir, generation)
3519        })
3520    }
3521
3522    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3523        self.writer_lease.as_ref().map(|lease| lease.epoch())
3524    }
3525
3526    fn verify_writer_lease(&self) -> Result<()> {
3527        let Some(lease) = self.writer_lease.as_ref() else {
3528            return Err(CallGraphStoreError::Unavailable(
3529                "callgraph store opened read-only; write API is unavailable".to_string(),
3530            ));
3531        };
3532        verify_writer_lease(lease)
3533    }
3534
3535    fn refresh_read_marker(&self) -> Result<()> {
3536        if let Some(marker) = self.read_marker.as_ref() {
3537            marker.touch_if_due()?;
3538        }
3539        Ok(())
3540    }
3541
3542    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3543        self.write_metrics
3544            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3545    }
3546
3547    fn checkpoint_wal_truncate(&self) -> bool {
3548        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3549        checkpoint_wal_truncate(&conn)
3550    }
3551
3552    /// True if this store still reflects the currently-published generation.
3553    /// Cheap (one small pointer-file read). When false, another process (or a
3554    /// local cold rebuild) has published a newer generation and the holder
3555    /// should drop this store and reopen via the pointer to converge. A missing
3556    /// pointer keeps the current store (legacy DB still valid, or transient).
3557    pub fn is_current(&self) -> bool {
3558        let _ = self.refresh_read_marker();
3559        match (
3560            read_pointer(&self.publication_dir, &self.project_key),
3561            &self.generation,
3562        ) {
3563            // Even when both generations happen to have the same filename, the
3564            // root-keyed pointer names a different directory from the fallback.
3565            (Some(_), _) if self.legacy_fallback => false,
3566            (Some(published), Some(opened)) => &published == opened,
3567            // A generation now supersedes the legacy single-file DB we opened.
3568            (Some(_), None) => false,
3569            // No pointer: keep serving (legacy DB, or an anomalous pointer
3570            // removal where our open generation file is still valid).
3571            (None, _) => true,
3572        }
3573    }
3574
3575    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3576        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3577    }
3578
3579    /// Build in two durable passes. Discovery first commits a disk-backed file
3580    /// inventory, extraction consumes bounded batches from that inventory, and
3581    /// resolution pages through staged raw references after all symbols exist.
3582    pub fn cold_build_chunked(
3583        &self,
3584        files: &[PathBuf],
3585        chunk_size: usize,
3586    ) -> Result<ColdBuildStats> {
3587        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3588        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3589    }
3590
3591    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3592        note_cold_build_phase("enumeration");
3593        if files.is_empty() {
3594            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3595                &self.project_root,
3596            ))
3597        } else {
3598            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3599        }
3600    }
3601
3602    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3603    where
3604        I: IntoIterator<Item = PathBuf>,
3605    {
3606        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3607        self.verify_writer_lease()?;
3608        let total_changes_before = conn.total_changes();
3609        let tx = conn.transaction()?;
3610        tx.execute("DELETE FROM staging_file_inventory", [])?;
3611        tx.commit()?;
3612        self.record_commit(total_changes_before, &conn);
3613
3614        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3615        for path in paths {
3616            let path = normalize_file_path(&self.project_root, &path)?;
3617            let rel_path = relative_path(&self.project_root, &path);
3618            let size = std::fs::metadata(&path)
3619                .map(|metadata| metadata.len())
3620                .unwrap_or(0);
3621            batch.push((rel_path, size));
3622            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3623                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3624                batch.clear();
3625            }
3626        }
3627        if !batch.is_empty() {
3628            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3629        }
3630
3631        staged_corpus_fingerprint(&conn, &self.project_root)
3632    }
3633
3634    fn insert_staged_file_inventory_batch(
3635        &self,
3636        conn: &mut Connection,
3637        batch: &[(String, u64)],
3638    ) -> Result<()> {
3639        self.verify_writer_lease()?;
3640        let total_changes_before = conn.total_changes();
3641        let tx = conn.transaction()?;
3642        {
3643            let mut insert = tx.prepare(
3644                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3645            )?;
3646            for (path, size) in batch {
3647                insert.execute(params![path, *size as i64])?;
3648            }
3649        }
3650        tx.commit()?;
3651        self.record_commit(total_changes_before, conn);
3652        Ok(())
3653    }
3654
3655    fn cold_build_chunked_from_staged_inventory(
3656        &self,
3657        chunk_size: usize,
3658        corpus_fingerprint: &str,
3659    ) -> Result<ColdBuildStats> {
3660        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3661        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3662            chunk_size,
3663            corpus_fingerprint,
3664            COLD_BUILD_RESOLVE_WINDOW,
3665            &module_resolution_memo,
3666        )
3667    }
3668
3669    #[cfg(test)]
3670    fn cold_build_chunked_with_resolution_memo_for_test(
3671        &self,
3672        files: &[PathBuf],
3673        chunk_size: usize,
3674        resolve_window: usize,
3675        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3676    ) -> Result<ColdBuildStats> {
3677        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3678        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3679            chunk_size,
3680            &corpus_fingerprint,
3681            resolve_window.max(1),
3682            module_resolution_memo,
3683        )
3684    }
3685
3686    fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
3687        &self,
3688        chunk_size: usize,
3689        corpus_fingerprint: &str,
3690        resolve_window: usize,
3691        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3692    ) -> Result<ColdBuildStats> {
3693        let started = Instant::now();
3694        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
3695        let workspace_root = self.project_root.display().to_string();
3696        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3697
3698        self.verify_writer_lease()?;
3699        ensure_cold_build_current("staging-admission", 0, 1)?;
3700        let mut phase = staged_build_phase(&conn)?;
3701        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
3702        let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
3703        if phase.as_deref() == Some("ready") && fingerprint_matches {
3704            ensure_cold_build_current("completed-staging", 1, 1)?;
3705            crate::slog_info!(
3706                "callgraph cold-build decision: reason=matching completed staging; action=publish"
3707            );
3708            conn.execute("DELETE FROM staging_file_inventory", [])?;
3709            return cold_build_stats_from_connection(&conn, started);
3710        }
3711        if phase.is_none() || !fingerprint_matches {
3712            if staged_fingerprint.is_some() && !fingerprint_matches {
3713                crate::slog_info!(
3714                    "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
3715                );
3716            }
3717            let total_changes_before = conn.total_changes();
3718            let tx = conn.transaction()?;
3719            clear_tables(&tx)?;
3720            tx.execute("DELETE FROM staging_ref_context", [])?;
3721            insert_meta(&tx)?;
3722            drop_cold_build_secondary_indexes(&tx)?;
3723            set_meta_ready(&tx, false)?;
3724            set_staged_build_phase(&tx, "extracting")?;
3725            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
3726            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
3727            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
3728            tx.commit()?;
3729            self.record_commit(total_changes_before, &conn);
3730            phase = Some("extracting".to_string());
3731        }
3732
3733        // A crashed extraction pass has already committed complete batches. Compare the
3734        // staged content identity with the current file before parsing so unchanged
3735        // committed files are not restarted from zero after adoption.
3736        note_cold_build_phase("extraction");
3737        if phase.as_deref() == Some("extracting") {
3738            prune_staged_files_not_in_inventory(&mut conn)?;
3739
3740            let total_files =
3741                query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
3742            let mut completed_files = 0usize;
3743            ensure_cold_build_current("extraction", completed_files, total_files)?;
3744            let mut after_path = String::new();
3745            loop {
3746                let Some(batch) = load_staged_file_batch(
3747                    &conn,
3748                    &self.project_root,
3749                    &after_path,
3750                    batch_files,
3751                    COLD_BUILD_EXTRACT_BATCH_BYTES,
3752                )?
3753                else {
3754                    break;
3755                };
3756                after_path = batch.last_path;
3757                let batch_files = batch.paths.len();
3758
3759                let mut needs_extract = Vec::with_capacity(batch_files);
3760                for path in batch.paths {
3761                    if !staged_content_matches(&conn, &self.project_root, &path)? {
3762                        needs_extract.push(path);
3763                    }
3764                }
3765                if needs_extract.is_empty() {
3766                    completed_files = completed_files.saturating_add(batch_files);
3767                    ensure_cold_build_current("extraction", completed_files, total_files)?;
3768                    continue;
3769                }
3770
3771                notify_cold_build_extract_observer(&needs_extract);
3772                let build = build_extracts_parallel(&self.project_root, &needs_extract);
3773                self.verify_writer_lease()?;
3774                let total_changes_before = conn.total_changes();
3775                let tx = conn.transaction()?;
3776                let mut extracted_bytes = 0u64;
3777                {
3778                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3779                    for extract in &build.extracts {
3780                        delete_staged_file_rows(&tx, &extract.rel_path)?;
3781                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
3782                        for raw in &extract.raw_refs {
3783                            insert_staged_ref_prepared(&mut inserts, raw)?;
3784                        }
3785                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
3786                    }
3787                    for failure in &build.failures {
3788                        insert_backend_state_prepared(
3789                            &mut inserts.backend_state,
3790                            &workspace_root,
3791                            &failure.rel_path,
3792                            failure
3793                                .freshness
3794                                .as_ref()
3795                                .map(|freshness| &freshness.content_hash),
3796                            "stale",
3797                        )?;
3798                    }
3799                }
3800                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
3801                note_cold_build_commit_barrier("extraction_batch_before_commit");
3802                tx.commit()?;
3803                note_cold_build_commit_barrier("extraction_batch_committed");
3804                self.record_commit(total_changes_before, &conn);
3805                completed_files = completed_files.saturating_add(batch_files);
3806                ensure_cold_build_current("extraction", completed_files, total_files)?;
3807            }
3808
3809            ensure_cold_build_current("extraction", completed_files, total_files)?;
3810            let total_changes_before = conn.total_changes();
3811            let tx = conn.transaction()?;
3812            set_staged_build_phase(&tx, "indexing")?;
3813            tx.commit()?;
3814            self.record_commit(total_changes_before, &conn);
3815            phase = Some("indexing".to_string());
3816            ensure_cold_build_current("extraction", total_files, total_files)?;
3817        }
3818
3819        // Secondary indexes are intentionally created only after every extract is
3820        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
3821        // corpus-wide symbol/export table.
3822        note_cold_build_phase("symbol_export_index");
3823        if phase.as_deref() == Some("indexing") {
3824            ensure_cold_build_current("symbol-export-index", 0, 1)?;
3825            self.verify_writer_lease()?;
3826            let total_changes_before = conn.total_changes();
3827            let tx = conn.transaction()?;
3828            create_cold_build_secondary_indexes(&tx)?;
3829            set_staged_build_phase(&tx, "resolving")?;
3830            tx.commit()?;
3831            self.record_commit(total_changes_before, &conn);
3832            ensure_cold_build_current("symbol-export-index", 1, 1)?;
3833        }
3834
3835        note_cold_build_phase("resolution");
3836        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
3837        let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
3838        let mut resolved_refs =
3839            query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
3840        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3841        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
3842        loop {
3843            let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
3844            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
3845                break;
3846            };
3847
3848            self.verify_writer_lease()?;
3849            let total_changes_before = conn.total_changes();
3850            let tx = conn.transaction()?;
3851            {
3852                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
3853                let mut offset = 0;
3854                while offset < staged.len() {
3855                    let caller_file = staged[offset].raw.caller_file.clone();
3856                    let end = staged[offset..]
3857                        .iter()
3858                        .position(|entry| entry.raw.caller_file != caller_file)
3859                        .map(|relative| offset + relative)
3860                        .unwrap_or(staged.len());
3861                    let caller_extract = build_file_extract(
3862                        &self.project_root,
3863                        &self.project_root.join(&caller_file),
3864                    );
3865                    if let Ok(caller_extract) = caller_extract {
3866                        let index = DiskProjectIndex {
3867                            project_root: &self.project_root,
3868                            conn: &tx,
3869                            caller_file: &caller_file,
3870                            caller_data: &caller_extract.data,
3871                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
3872                            module_resolution_memo,
3873                        };
3874                        for staged_ref in &staged[offset..end] {
3875                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
3876                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
3877                        }
3878                    } else {
3879                        for staged_ref in &staged[offset..end] {
3880                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
3881                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
3882                        }
3883                    }
3884                    offset = end;
3885                }
3886            }
3887            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
3888            tx.commit()?;
3889            self.record_commit(total_changes_before, &conn);
3890            resolve_cursor = last_rowid;
3891            resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
3892            ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3893        }
3894
3895        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
3896        note_cold_build_phase("publication");
3897        self.verify_writer_lease()?;
3898        let total_changes_before = conn.total_changes();
3899        let tx = conn.transaction()?;
3900        let _supplemental_edge_count =
3901            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
3902        set_meta_ready(&tx, true)?;
3903        set_staged_build_phase(&tx, "ready")?;
3904        tx.execute("DELETE FROM staging_file_inventory", [])?;
3905        tx.execute("DELETE FROM staging_ref_context", [])?;
3906        bump_projection_write_revision(&tx)?;
3907        tx.commit()?;
3908        self.record_commit(total_changes_before, &conn);
3909
3910        cold_build_stats_from_connection(&conn, started)
3911    }
3912
3913    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
3914        self.refresh_files_with_workspace_crate_prefix_cache(
3915            changed_files,
3916            WorkspaceCratePrefixCache::default(),
3917        )
3918    }
3919
3920    fn refresh_files_with_workspace_crate_prefix_cache(
3921        &self,
3922        changed_files: &[PathBuf],
3923        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3924    ) -> Result<IncrementalStats> {
3925        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3926            changed_files,
3927            workspace_crate_prefixes,
3928        )?;
3929        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
3930            eprintln!("refresh_files phases: {}", profile.report());
3931        }
3932        Ok(stats)
3933    }
3934
3935    /// Run an incremental refresh and return phase timings for an offline store copy.
3936    #[doc(hidden)]
3937    pub fn refresh_files_profiled(
3938        &self,
3939        changed_files: &[PathBuf],
3940    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3941        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
3942            changed_files,
3943            WorkspaceCratePrefixCache::default(),
3944        )
3945    }
3946
3947    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
3948        &self,
3949        changed_files: &[PathBuf],
3950        workspace_crate_prefixes: WorkspaceCratePrefixCache,
3951    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
3952        let total_started = Instant::now();
3953        let mut profile = RefreshFilesProfile::default();
3954        self.verify_writer_lease()?;
3955        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3956        ensure_database_ready(&conn)?;
3957        let total_changes_before = conn.total_changes();
3958        let mut changed = Vec::new();
3959        let mut surface_changed = BTreeSet::new();
3960        let mut deleted = BTreeSet::new();
3961        let mut own_refresh = BTreeSet::new();
3962        let mut candidate_own_refresh = BTreeSet::new();
3963        let mut confirmed_fresh = BTreeSet::new();
3964        let mut unchanged_extracts = 0usize;
3965        let mut selected_ref_ids = BTreeSet::new();
3966        let mut selected_refs_by_caller = BTreeMap::new();
3967        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
3968        let mut fresh_metadata = BTreeMap::new();
3969
3970        for input in changed_files {
3971            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
3972            {
3973                Ok(path) => path,
3974                Err(error) => {
3975                    record_path_identity_mismatch(&conn, &error)?;
3976                    return Err(error);
3977                }
3978            };
3979            changed.push(rel_path.clone());
3980            let old_row = load_file_row(&conn, &rel_path)?;
3981            if !abs_path.exists() {
3982                if old_row.is_some() && deleted.insert(rel_path.clone()) {
3983                    surface_changed.insert(rel_path.clone());
3984                    let started = Instant::now();
3985                    let dependent_refs =
3986                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
3987                    profile.dependency_selection += started.elapsed();
3988                    record_dependent_refs(
3989                        &mut selected_ref_ids,
3990                        &mut selected_refs_by_caller,
3991                        dependent_refs,
3992                    );
3993                }
3994                continue;
3995            }
3996
3997            if let Some(row) = &old_row {
3998                match cache_freshness::verify_file(&abs_path, &row.freshness) {
3999                    FreshnessVerdict::HotFresh => {
4000                        // Content still matches the stored graph. A prior failed
4001                        // refresh may have left backend_file_state='stale' without
4002                        // changing bytes; skip the extract but still clear that
4003                        // leftover so dead-code projection can use this store.
4004                        confirmed_fresh.insert(rel_path.clone());
4005                        continue;
4006                    }
4007                    FreshnessVerdict::ContentFresh {
4008                        new_mtime,
4009                        new_size,
4010                    } => {
4011                        fresh_metadata.insert(
4012                            rel_path.clone(),
4013                            FileFreshness {
4014                                content_hash: row.freshness.content_hash,
4015                                mtime: new_mtime,
4016                                size: new_size,
4017                            },
4018                        );
4019                        continue;
4020                    }
4021                    FreshnessVerdict::Deleted => {
4022                        if deleted.insert(rel_path.clone()) {
4023                            surface_changed.insert(rel_path.clone());
4024                            let started = Instant::now();
4025                            let dependent_refs =
4026                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4027                            profile.dependency_selection += started.elapsed();
4028                            record_dependent_refs(
4029                                &mut selected_ref_ids,
4030                                &mut selected_refs_by_caller,
4031                                dependent_refs,
4032                            );
4033                        }
4034                        continue;
4035                    }
4036                    FreshnessVerdict::Stale => {}
4037                }
4038            }
4039
4040            let started = Instant::now();
4041            let extract = build_file_extract(&self.project_root, &abs_path)?;
4042            profile.parse += started.elapsed();
4043            let surface_is_changed = old_row
4044                .as_ref()
4045                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
4046                .unwrap_or(true);
4047            if surface_is_changed {
4048                surface_changed.insert(rel_path.clone());
4049                let started = Instant::now();
4050                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4051                profile.dependency_selection += started.elapsed();
4052                record_dependent_refs(
4053                    &mut selected_ref_ids,
4054                    &mut selected_refs_by_caller,
4055                    dependent_refs,
4056                );
4057            }
4058            candidate_own_refresh.insert(rel_path.clone());
4059            changed_extracts.insert(rel_path, extract);
4060        }
4061
4062        let dependency_selected_refs = selected_ref_ids.len();
4063        let mut touched_callers: BTreeSet<String> =
4064            selected_refs_by_caller.keys().cloned().collect();
4065        touched_callers.extend(candidate_own_refresh.iter().cloned());
4066
4067        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
4068        for rel_path in &touched_callers {
4069            if deleted.contains(rel_path) {
4070                continue;
4071            }
4072            if let Some(extract) = changed_extracts.get(rel_path) {
4073                caller_extracts.insert(rel_path.clone(), extract.clone());
4074                continue;
4075            }
4076            let abs_path = self.project_root.join(rel_path);
4077            if abs_path.exists() {
4078                let started = Instant::now();
4079                let extract = build_file_extract(&self.project_root, &abs_path)?;
4080                profile.dependent_parse += started.elapsed();
4081                caller_extracts.insert(rel_path.clone(), extract);
4082            }
4083        }
4084
4085        let tx = conn.transaction()?;
4086        for (rel_path, freshness) in fresh_metadata {
4087            update_file_fresh_metadata(
4088                &tx,
4089                &self.project_root,
4090                &rel_path,
4091                &freshness.content_hash,
4092                freshness.mtime,
4093                freshness.size,
4094            )?;
4095        }
4096        for rel_path in &confirmed_fresh {
4097            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4098        }
4099        for rel_path in &deleted {
4100            let started = Instant::now();
4101            delete_file_rows(&tx, rel_path)?;
4102            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4103            profile.row_deletes += started.elapsed();
4104        }
4105
4106        let started = Instant::now();
4107        let index = ProjectIndex::from_db_and_callers(
4108            &tx,
4109            &self.project_root,
4110            &caller_extracts,
4111            workspace_crate_prefixes,
4112        )?;
4113        profile.index_load += started.elapsed();
4114
4115        let workspace_root = self.project_root.display().to_string();
4116        {
4117            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4118            for rel_path in &candidate_own_refresh {
4119                let Some(extract) = changed_extracts.get(rel_path) else {
4120                    continue;
4121                };
4122                if !write_amplification_baseline_enabled()
4123                    && stored_extract_matches(&tx, rel_path, extract, &index)?
4124                {
4125                    unchanged_extracts += 1;
4126                    update_file_fresh_metadata(
4127                        &tx,
4128                        &self.project_root,
4129                        rel_path,
4130                        &extract.freshness.content_hash,
4131                        extract.freshness.mtime,
4132                        extract.freshness.size,
4133                    )?;
4134                    continue;
4135                }
4136
4137                own_refresh.insert(rel_path.clone());
4138                let started = Instant::now();
4139                delete_file_rows(&tx, rel_path)?;
4140                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4141                profile.row_deletes += started.elapsed();
4142                let started = Instant::now();
4143                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4144                profile.row_inserts += started.elapsed();
4145            }
4146
4147            let dependency_callers = touched_callers
4148                .iter()
4149                .filter(|rel_path| {
4150                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4151                })
4152                .cloned()
4153                .collect::<Vec<_>>();
4154            for rel_path in dependency_callers {
4155                let Some(extract) = caller_extracts.get(&rel_path) else {
4156                    continue;
4157                };
4158                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4159                    continue;
4160                }
4161
4162                own_refresh.insert(rel_path.clone());
4163                let started = Instant::now();
4164                delete_file_rows(&tx, &rel_path)?;
4165                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4166                profile.row_deletes += started.elapsed();
4167                let started = Instant::now();
4168                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4169                profile.row_inserts += started.elapsed();
4170            }
4171            let started = Instant::now();
4172            for rel_path in &touched_callers {
4173                if deleted.contains(rel_path) {
4174                    continue;
4175                }
4176                let Some(extract) = caller_extracts.get(rel_path) else {
4177                    continue;
4178                };
4179                if own_refresh.contains(rel_path) {
4180                    delete_refs_for_caller(&tx, rel_path)?;
4181                    for raw_ref in &extract.raw_refs {
4182                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4183                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4184                    }
4185                    continue;
4186                }
4187
4188                let selected_for_caller = selected_refs_by_caller
4189                    .get(rel_path)
4190                    .cloned()
4191                    .unwrap_or_default();
4192                delete_ref_ids(&tx, &selected_for_caller)?;
4193                for raw_ref in &extract.raw_refs {
4194                    if selected_for_caller.contains(&raw_ref.ref_id) {
4195                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4196                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4197                    }
4198                }
4199            }
4200            profile.ref_resolution += started.elapsed();
4201        }
4202
4203        let started = Instant::now();
4204        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4205        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4206        profile.method_dispatch += started.elapsed();
4207
4208        bump_projection_write_revision(&tx)?;
4209        let started = Instant::now();
4210        commit_incremental_if_current(tx)?;
4211        self.record_commit(total_changes_before, &conn);
4212        profile.commit += started.elapsed();
4213        profile.total = total_started.elapsed();
4214        Ok((
4215            IncrementalStats {
4216                changed_files: changed,
4217                surface_changed: surface_changed.into_iter().collect(),
4218                deleted_files: deleted.into_iter().collect(),
4219                dependency_selected_refs,
4220                refreshed_own_files: own_refresh.len(),
4221                unchanged_extract_files: unchanged_extracts,
4222            },
4223            profile,
4224        ))
4225    }
4226
4227    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4228        self.cold_build(current_files)
4229    }
4230
4231    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4232        self.verify_writer_lease()?;
4233        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4234        let total_changes_before = conn.total_changes();
4235        let tx = conn.transaction()?;
4236        let mut marked = Vec::new();
4237        for path in files {
4238            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4239                Ok(path) => path,
4240                Err(error) => {
4241                    drop(tx);
4242                    record_path_identity_mismatch(&conn, &error)?;
4243                    return Err(error);
4244                }
4245            };
4246            let freshness = cache_freshness::collect(&abs_path).ok();
4247            mark_backend_state(
4248                &tx,
4249                &self.project_root,
4250                &rel_path,
4251                freshness.as_ref().map(|freshness| &freshness.content_hash),
4252                "stale",
4253            )?;
4254            marked.push(rel_path);
4255        }
4256        bump_projection_write_revision(&tx)?;
4257        tx.commit()?;
4258        self.record_commit(total_changes_before, &conn);
4259        marked.sort();
4260        marked.dedup();
4261        Ok(marked)
4262    }
4263
4264    pub fn stale_files(&self) -> Result<Vec<String>> {
4265        self.refresh_read_marker()?;
4266        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4267        let mut stmt = conn.prepare(
4268            "SELECT DISTINCT file_path FROM backend_file_state
4269             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4270             ORDER BY file_path",
4271        )?;
4272        let rows = stmt.query_map(
4273            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4274            |row| row.get::<_, String>(0),
4275        )?;
4276        rows.collect::<std::result::Result<Vec<_>, _>>()
4277            .map_err(Into::into)
4278    }
4279
4280    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4281        self.refresh_read_marker()?;
4282        let rel_path = relative_path(
4283            &self.project_root,
4284            &normalize_file_path(&self.project_root, file)?,
4285        );
4286        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4287        conn.query_row(
4288            "SELECT status FROM backend_file_state
4289             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4290             ORDER BY updated_at DESC LIMIT 1",
4291            params![
4292                BACKEND_TREESITTER,
4293                self.project_root.display().to_string(),
4294                rel_path
4295            ],
4296            |row| row.get(0),
4297        )
4298        .optional()
4299        .map_err(Into::into)
4300    }
4301
4302    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4303        self.refresh_read_marker()?;
4304        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4305        self.ensure_ready(&conn)?;
4306        edge_snapshot_with_conn(&conn)
4307    }
4308
4309    pub fn indexed_file_count(&self) -> Result<usize> {
4310        self.refresh_read_marker()?;
4311        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4312        self.ensure_ready(&conn)?;
4313        indexed_file_count(&conn)
4314    }
4315
4316    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4317        self.refresh_read_marker()?;
4318        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4319        let rel_path = relative_path(&self.project_root, &abs_path);
4320        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4321        self.ensure_ready(&conn)?;
4322        resolve_node_for_rel(&conn, &rel_path, symbol)
4323    }
4324
4325    /// Return all positional nodes matching a legacy symbol query in a file.
4326    ///
4327    /// Consumers that need legacy compatibility can collapse these by
4328    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
4329    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4330        self.refresh_read_marker()?;
4331        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4332        let rel_path = relative_path(&self.project_root, &abs_path);
4333        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4334        self.ensure_ready(&conn)?;
4335        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4336    }
4337
4338    /// Return all positional nodes matching a symbol query anywhere in the store.
4339    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4340        self.refresh_read_marker()?;
4341        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4342        self.ensure_ready(&conn)?;
4343        nodes_matching_symbol(&conn, symbol)
4344    }
4345
4346    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
4347    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4348        self.refresh_read_marker()?;
4349        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4350        let rel_path = relative_path(&self.project_root, &abs_path);
4351        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4352        self.ensure_ready(&conn)?;
4353        direct_callers_for_tuple(&conn, &rel_path, symbol)
4354    }
4355
4356    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
4357    pub fn direct_callers_for_symbols(
4358        &self,
4359        targets: &[(String, String)],
4360    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4361        if targets.is_empty() {
4362            return Ok(HashMap::new());
4363        }
4364        self.refresh_read_marker()?;
4365        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4366        self.ensure_ready(&conn)?;
4367        direct_callers_for_tuples(&conn, targets)
4368    }
4369
4370    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
4371    pub fn direct_caller_counts_of(
4372        &self,
4373        targets: &[(String, String)],
4374    ) -> Result<HashMap<(String, String), usize>> {
4375        if targets.is_empty() {
4376            return Ok(HashMap::new());
4377        }
4378        self.refresh_read_marker()?;
4379        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4380        self.ensure_ready(&conn)?;
4381        direct_caller_counts_for_tuples(&conn, targets)
4382    }
4383
4384    pub fn callers_of(
4385        &self,
4386        file_rel: &Path,
4387        symbol: &str,
4388        depth: usize,
4389    ) -> Result<StoreCallersResult> {
4390        let target = self.node_for(file_rel, symbol)?;
4391        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4392        self.ensure_ready(&conn)?;
4393        let effective_depth = depth.max(1);
4394        let mut visited = HashSet::new();
4395        let mut callers = Vec::new();
4396        let mut depth_limited = false;
4397        let mut truncated = 0usize;
4398        collect_callers_recursive(
4399            &conn,
4400            &target.file,
4401            &target.symbol,
4402            effective_depth,
4403            0,
4404            &mut visited,
4405            &mut callers,
4406            &mut depth_limited,
4407            &mut truncated,
4408        )?;
4409        Ok(StoreCallersResult {
4410            target,
4411            callers,
4412            scanned_files: indexed_file_count(&conn)?,
4413            depth_limited,
4414            truncated,
4415        })
4416    }
4417
4418    pub fn impact_of(
4419        &self,
4420        file_rel: &Path,
4421        symbol: &str,
4422        depth: usize,
4423    ) -> Result<StoreImpactResult> {
4424        let callers = self.callers_of(file_rel, symbol, depth)?;
4425        let target_parameters = callers
4426            .target
4427            .signature
4428            .as_deref()
4429            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4430            .unwrap_or_default();
4431        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4432        for site in &callers.callers {
4433            source_lines_by_file
4434                .entry(site.caller.file.clone())
4435                .or_insert_with(|| {
4436                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4437                });
4438        }
4439        let enriched = callers
4440            .callers
4441            .iter()
4442            .map(|site| StoreImpactCaller {
4443                site: site.clone(),
4444                signature: site.caller.signature.clone(),
4445                is_entry_point: site.caller.is_entry_point,
4446                call_expression: source_lines_by_file
4447                    .get(&site.caller.file)
4448                    .and_then(|lines| lines.as_ref())
4449                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4450                    .cloned(),
4451                parameters: site
4452                    .caller
4453                    .signature
4454                    .as_deref()
4455                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4456                    .unwrap_or_default(),
4457            })
4458            .collect();
4459        Ok(StoreImpactResult {
4460            target: callers.target,
4461            parameters: target_parameters,
4462            callers: enriched,
4463            depth_limited: callers.depth_limited,
4464            truncated: callers.truncated,
4465        })
4466    }
4467
4468    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4469        self.refresh_read_marker()?;
4470        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4471        self.ensure_ready(&conn)?;
4472        outgoing_calls_for_node(&conn, node)
4473    }
4474
4475    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4476    pub fn outgoing_calls_for_symbols(
4477        &self,
4478        sources: &[(String, String)],
4479    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4480        if sources.is_empty() {
4481            return Ok(HashMap::new());
4482        }
4483        self.refresh_read_marker()?;
4484        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4485        self.ensure_ready(&conn)?;
4486        outgoing_calls_for_symbol_tuples(&conn, sources)
4487    }
4488
4489    /// Return resolved direct self-call refs suppressed from the general edge table.
4490    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4491        self.refresh_read_marker()?;
4492        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4493        self.ensure_ready(&conn)?;
4494        resolved_self_calls_for_node(&conn, node)
4495    }
4496
4497    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4498        self.refresh_read_marker()?;
4499        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4500        self.ensure_ready(&conn)?;
4501        unresolved_calls_for_node(&conn, node)
4502    }
4503
4504    pub fn call_tree(
4505        &self,
4506        file_rel: &Path,
4507        symbol: &str,
4508        max_depth: usize,
4509    ) -> Result<callgraph::CallTreeNode> {
4510        let node = self.node_for(file_rel, symbol)?;
4511        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4512        self.ensure_ready(&conn)?;
4513        let mut visited = HashSet::new();
4514        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4515    }
4516
4517    pub fn trace_to(
4518        &self,
4519        file_rel: &Path,
4520        symbol: &str,
4521        max_depth: usize,
4522    ) -> Result<callgraph::TraceToResult> {
4523        let target = self.node_for(file_rel, symbol)?;
4524        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4525        self.ensure_ready(&conn)?;
4526        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4527
4528        #[derive(Clone)]
4529        struct PathElem {
4530            node: StoreNode,
4531        }
4532
4533        let initial = vec![PathElem {
4534            node: target.clone(),
4535        }];
4536        let mut complete_paths = Vec::new();
4537        if target.is_entry_point {
4538            complete_paths.push(initial.clone());
4539        }
4540
4541        let mut queue = vec![(initial, 0usize)];
4542        let mut max_depth_reached = false;
4543        let mut truncated_paths = 0usize;
4544
4545        while let Some((path, depth)) = queue.pop() {
4546            if depth >= effective_max {
4547                max_depth_reached = true;
4548                continue;
4549            }
4550            let Some(current) = path.last() else {
4551                continue;
4552            };
4553            let callers =
4554                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4555            if callers.is_empty() {
4556                if path.len() > 1 {
4557                    truncated_paths += 1;
4558                }
4559                continue;
4560            }
4561
4562            let mut has_new_path = false;
4563            for site in callers {
4564                if path.iter().any(|elem| {
4565                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4566                }) {
4567                    continue;
4568                }
4569                has_new_path = true;
4570                let mut new_path = path.clone();
4571                new_path.push(PathElem {
4572                    node: site.caller.clone(),
4573                });
4574                if site.caller.is_entry_point {
4575                    complete_paths.push(new_path.clone());
4576                }
4577                queue.push((new_path, depth + 1));
4578            }
4579            if !has_new_path && path.len() > 1 {
4580                truncated_paths += 1;
4581            }
4582        }
4583
4584        let mut paths: Vec<callgraph::TracePath> = complete_paths
4585            .into_iter()
4586            .map(|mut elems| {
4587                elems.reverse();
4588                let hops = elems
4589                    .iter()
4590                    .enumerate()
4591                    .map(|(index, elem)| callgraph::TraceHop {
4592                        symbol: elem.node.symbol.clone(),
4593                        file: elem.node.file.clone(),
4594                        line: elem.node.line,
4595                        signature: elem.node.signature.clone(),
4596                        is_entry_point: index == 0 && elem.node.is_entry_point,
4597                    })
4598                    .collect();
4599                callgraph::TracePath { hops }
4600            })
4601            .collect();
4602        paths.sort_by(|left, right| {
4603            let left_entry = left
4604                .hops
4605                .first()
4606                .map(|hop| hop.symbol.as_str())
4607                .unwrap_or("");
4608            let right_entry = right
4609                .hops
4610                .first()
4611                .map(|hop| hop.symbol.as_str())
4612                .unwrap_or("");
4613            left_entry
4614                .cmp(right_entry)
4615                .then(left.hops.len().cmp(&right.hops.len()))
4616        });
4617        let entry_points_found = paths
4618            .iter()
4619            .filter_map(|path| path.hops.first())
4620            .filter(|hop| hop.is_entry_point)
4621            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4622            .collect::<HashSet<_>>()
4623            .len();
4624
4625        Ok(callgraph::TraceToResult {
4626            target_symbol: target.symbol,
4627            target_file: target.file,
4628            total_paths: paths.len(),
4629            paths,
4630            entry_points_found,
4631            max_depth_reached,
4632            truncated_paths,
4633        })
4634    }
4635
4636    pub fn trace_to_symbol_candidates(
4637        &self,
4638        to_symbol: &str,
4639    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
4640        self.refresh_read_marker()?;
4641        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4642        self.ensure_ready(&conn)?;
4643        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
4644        for node in nodes_matching_symbol(&conn, to_symbol)? {
4645            candidates_by_file
4646                .entry(node.file)
4647                .and_modify(|line| *line = (*line).min(node.line))
4648                .or_insert(node.line);
4649        }
4650        let mut candidates: Vec<_> = candidates_by_file
4651            .into_iter()
4652            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
4653            .collect();
4654        candidates
4655            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
4656        Ok(candidates)
4657    }
4658
4659    pub fn trace_to_symbol(
4660        &self,
4661        file_rel: &Path,
4662        symbol: &str,
4663        to_symbol: &str,
4664        to_file: Option<&Path>,
4665        max_depth: usize,
4666    ) -> Result<callgraph::TraceToSymbolResult> {
4667        let origin = self.node_for(file_rel, symbol)?;
4668        let target_file = to_file
4669            .map(|path| normalize_file_path(&self.project_root, path))
4670            .transpose()?
4671            .map(|path| relative_path(&self.project_root, &path));
4672        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4673        self.ensure_ready(&conn)?;
4674        let effective_max = if max_depth == 0 {
4675            10
4676        } else {
4677            max_depth.min(16)
4678        };
4679
4680        let start_hop = trace_to_symbol_hop(&origin);
4681        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
4682            return Ok(callgraph::TraceToSymbolResult {
4683                path: Some(vec![start_hop]),
4684                complete: true,
4685                reason: None,
4686            });
4687        }
4688
4689        let mut queue = VecDeque::new();
4690        queue.push_back((origin.clone(), vec![start_hop], 0usize));
4691        let mut visited = HashSet::new();
4692        visited.insert((origin.file.clone(), origin.symbol.clone()));
4693        let mut max_depth_exhausted = false;
4694
4695        while let Some((current, path, depth)) = queue.pop_front() {
4696            let callees = outgoing_calls_for_node(&conn, &current)?
4697                .into_iter()
4698                .filter_map(|site| site.target)
4699                .collect::<Vec<_>>();
4700
4701            if depth >= effective_max {
4702                if callees
4703                    .iter()
4704                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
4705                {
4706                    max_depth_exhausted = true;
4707                }
4708                continue;
4709            }
4710
4711            for callee in callees {
4712                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
4713                    continue;
4714                }
4715                let mut next_path = path.clone();
4716                next_path.push(trace_to_symbol_hop(&callee));
4717                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
4718                    return Ok(callgraph::TraceToSymbolResult {
4719                        path: Some(next_path),
4720                        complete: true,
4721                        reason: None,
4722                    });
4723                }
4724                queue.push_back((callee, next_path, depth + 1));
4725            }
4726        }
4727
4728        if max_depth_exhausted {
4729            Ok(callgraph::TraceToSymbolResult {
4730                path: None,
4731                complete: false,
4732                reason: Some("max_depth_exhausted".to_string()),
4733            })
4734        } else {
4735            Ok(callgraph::TraceToSymbolResult {
4736                path: None,
4737                complete: true,
4738                reason: Some("no_path_found".to_string()),
4739            })
4740        }
4741    }
4742}
4743
4744impl ReadonlyCallGraphStore {
4745    fn from_inner(inner: CallGraphStore) -> Self {
4746        Self { inner }
4747    }
4748
4749    pub fn project_root(&self) -> &Path {
4750        self.inner.project_root()
4751    }
4752
4753    pub fn project_key(&self) -> &str {
4754        self.inner.project_key()
4755    }
4756
4757    pub fn sqlite_path(&self) -> &Path {
4758        self.inner.sqlite_path()
4759    }
4760
4761    pub fn stale_files(&self) -> Result<Vec<String>> {
4762        self.inner.stale_files()
4763    }
4764
4765    pub(crate) fn projection_generation(&self) -> Option<&str> {
4766        self.inner.projection_generation()
4767    }
4768
4769    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
4770        self.inner.projection_write_revision()
4771    }
4772
4773    /// Report the open generation handle. SQLite-owned allocations are measured
4774    /// once by the process-wide SQLite allocator counters.
4775    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
4776        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
4777    }
4778
4779    /// Whether this reader is temporarily serving a legacy harness partition.
4780    pub fn is_legacy_fallback(&self) -> bool {
4781        self.inner.is_legacy_fallback()
4782    }
4783
4784    pub fn is_current(&self) -> bool {
4785        self.inner.is_current()
4786    }
4787
4788    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4789        self.inner.edge_snapshot()
4790    }
4791
4792    pub fn indexed_file_count(&self) -> Result<usize> {
4793        self.inner.indexed_file_count()
4794    }
4795
4796    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4797        self.inner.node_for(file_rel, symbol)
4798    }
4799
4800    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4801        self.inner.nodes_for(file_rel, symbol)
4802    }
4803
4804    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4805        self.inner.nodes_matching(symbol)
4806    }
4807
4808    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4809        self.inner.direct_callers_of(file_rel, symbol)
4810    }
4811
4812    pub fn direct_callers_for_symbols(
4813        &self,
4814        targets: &[(String, String)],
4815    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4816        self.inner.direct_callers_for_symbols(targets)
4817    }
4818
4819    pub fn direct_caller_counts_of(
4820        &self,
4821        targets: &[(String, String)],
4822    ) -> Result<HashMap<(String, String), usize>> {
4823        self.inner.direct_caller_counts_of(targets)
4824    }
4825
4826    pub fn callers_of(
4827        &self,
4828        file_rel: &Path,
4829        symbol: &str,
4830        depth: usize,
4831    ) -> Result<StoreCallersResult> {
4832        self.inner.callers_of(file_rel, symbol, depth)
4833    }
4834
4835    pub fn impact_of(
4836        &self,
4837        file_rel: &Path,
4838        symbol: &str,
4839        depth: usize,
4840    ) -> Result<StoreImpactResult> {
4841        self.inner.impact_of(file_rel, symbol, depth)
4842    }
4843
4844    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4845        self.inner.outgoing_calls_of(node)
4846    }
4847
4848    pub fn outgoing_calls_for_symbols(
4849        &self,
4850        sources: &[(String, String)],
4851    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4852        self.inner.outgoing_calls_for_symbols(sources)
4853    }
4854
4855    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4856        self.inner.resolved_self_calls_of(node)
4857    }
4858
4859    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4860        self.inner.unresolved_calls_of(node)
4861    }
4862
4863    pub fn call_tree(
4864        &self,
4865        file_rel: &Path,
4866        symbol: &str,
4867        depth: usize,
4868    ) -> Result<callgraph::CallTreeNode> {
4869        self.inner.call_tree(file_rel, symbol, depth)
4870    }
4871
4872    pub fn trace_to(
4873        &self,
4874        file_rel: &Path,
4875        symbol: &str,
4876        max_depth: usize,
4877    ) -> Result<callgraph::TraceToResult> {
4878        self.inner.trace_to(file_rel, symbol, max_depth)
4879    }
4880
4881    pub fn trace_to_symbol_candidates(
4882        &self,
4883        to_symbol: &str,
4884    ) -> Result<Vec<TraceToSymbolCandidate>> {
4885        self.inner.trace_to_symbol_candidates(to_symbol)
4886    }
4887
4888    pub fn trace_to_symbol(
4889        &self,
4890        file_rel: &Path,
4891        symbol: &str,
4892        to_symbol: &str,
4893        to_file: Option<&Path>,
4894        max_depth: usize,
4895    ) -> Result<callgraph::TraceToSymbolResult> {
4896        self.inner
4897            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
4898    }
4899}
4900
4901impl CallGraphRead for CallGraphStore {
4902    fn project_root(&self) -> &Path {
4903        CallGraphStore::project_root(self)
4904    }
4905    fn project_key(&self) -> &str {
4906        CallGraphStore::project_key(self)
4907    }
4908    fn sqlite_path(&self) -> &Path {
4909        CallGraphStore::sqlite_path(self)
4910    }
4911    fn is_current(&self) -> bool {
4912        CallGraphStore::is_current(self)
4913    }
4914    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4915        CallGraphStore::edge_snapshot(self)
4916    }
4917    fn indexed_file_count(&self) -> Result<usize> {
4918        CallGraphStore::indexed_file_count(self)
4919    }
4920    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4921        CallGraphStore::node_for(self, file_rel, symbol)
4922    }
4923    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4924        CallGraphStore::nodes_for(self, file_rel, symbol)
4925    }
4926    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4927        CallGraphStore::nodes_matching(self, symbol)
4928    }
4929    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4930        CallGraphStore::direct_callers_of(self, file_rel, symbol)
4931    }
4932    fn direct_callers_for_symbols(
4933        &self,
4934        targets: &[(String, String)],
4935    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4936        CallGraphStore::direct_callers_for_symbols(self, targets)
4937    }
4938    fn direct_caller_counts_of(
4939        &self,
4940        targets: &[(String, String)],
4941    ) -> Result<HashMap<(String, String), usize>> {
4942        CallGraphStore::direct_caller_counts_of(self, targets)
4943    }
4944    fn callers_of(
4945        &self,
4946        file_rel: &Path,
4947        symbol: &str,
4948        depth: usize,
4949    ) -> Result<StoreCallersResult> {
4950        CallGraphStore::callers_of(self, file_rel, symbol, depth)
4951    }
4952    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
4953        CallGraphStore::impact_of(self, file_rel, symbol, depth)
4954    }
4955    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4956        CallGraphStore::outgoing_calls_of(self, node)
4957    }
4958    fn outgoing_calls_for_symbols(
4959        &self,
4960        sources: &[(String, String)],
4961    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4962        CallGraphStore::outgoing_calls_for_symbols(self, sources)
4963    }
4964    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4965        CallGraphStore::resolved_self_calls_of(self, node)
4966    }
4967    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4968        CallGraphStore::unresolved_calls_of(self, node)
4969    }
4970    fn call_tree(
4971        &self,
4972        file_rel: &Path,
4973        symbol: &str,
4974        depth: usize,
4975    ) -> Result<callgraph::CallTreeNode> {
4976        CallGraphStore::call_tree(self, file_rel, symbol, depth)
4977    }
4978    fn trace_to(
4979        &self,
4980        file_rel: &Path,
4981        symbol: &str,
4982        max_depth: usize,
4983    ) -> Result<callgraph::TraceToResult> {
4984        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
4985    }
4986    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
4987        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
4988    }
4989    fn trace_to_symbol(
4990        &self,
4991        file_rel: &Path,
4992        symbol: &str,
4993        to_symbol: &str,
4994        to_file: Option<&Path>,
4995        max_depth: usize,
4996    ) -> Result<callgraph::TraceToSymbolResult> {
4997        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
4998    }
4999}
5000
5001impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
5002    fn project_root(&self) -> &Path {
5003        (**self).project_root()
5004    }
5005    fn project_key(&self) -> &str {
5006        (**self).project_key()
5007    }
5008    fn sqlite_path(&self) -> &Path {
5009        (**self).sqlite_path()
5010    }
5011    fn is_current(&self) -> bool {
5012        (**self).is_current()
5013    }
5014    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5015        (**self).edge_snapshot()
5016    }
5017    fn indexed_file_count(&self) -> Result<usize> {
5018        (**self).indexed_file_count()
5019    }
5020    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5021        (**self).node_for(file_rel, symbol)
5022    }
5023    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5024        (**self).nodes_for(file_rel, symbol)
5025    }
5026    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5027        (**self).nodes_matching(symbol)
5028    }
5029    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5030        (**self).direct_callers_of(file_rel, symbol)
5031    }
5032    fn direct_callers_for_symbols(
5033        &self,
5034        targets: &[(String, String)],
5035    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5036        (**self).direct_callers_for_symbols(targets)
5037    }
5038    fn direct_caller_counts_of(
5039        &self,
5040        targets: &[(String, String)],
5041    ) -> Result<HashMap<(String, String), usize>> {
5042        (**self).direct_caller_counts_of(targets)
5043    }
5044    fn callers_of(
5045        &self,
5046        file_rel: &Path,
5047        symbol: &str,
5048        depth: usize,
5049    ) -> Result<StoreCallersResult> {
5050        (**self).callers_of(file_rel, symbol, depth)
5051    }
5052    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5053        (**self).impact_of(file_rel, symbol, depth)
5054    }
5055    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5056        (**self).outgoing_calls_of(node)
5057    }
5058    fn outgoing_calls_for_symbols(
5059        &self,
5060        sources: &[(String, String)],
5061    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5062        (**self).outgoing_calls_for_symbols(sources)
5063    }
5064    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5065        (**self).resolved_self_calls_of(node)
5066    }
5067    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5068        (**self).unresolved_calls_of(node)
5069    }
5070    fn call_tree(
5071        &self,
5072        file_rel: &Path,
5073        symbol: &str,
5074        depth: usize,
5075    ) -> Result<callgraph::CallTreeNode> {
5076        (**self).call_tree(file_rel, symbol, depth)
5077    }
5078    fn trace_to(
5079        &self,
5080        file_rel: &Path,
5081        symbol: &str,
5082        max_depth: usize,
5083    ) -> Result<callgraph::TraceToResult> {
5084        (**self).trace_to(file_rel, symbol, max_depth)
5085    }
5086    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5087        (**self).trace_to_symbol_candidates(to_symbol)
5088    }
5089    fn trace_to_symbol(
5090        &self,
5091        file_rel: &Path,
5092        symbol: &str,
5093        to_symbol: &str,
5094        to_file: Option<&Path>,
5095        max_depth: usize,
5096    ) -> Result<callgraph::TraceToSymbolResult> {
5097        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5098    }
5099}
5100
5101impl CallGraphRead for ReadonlyCallGraphStore {
5102    fn project_root(&self) -> &Path {
5103        self.project_root()
5104    }
5105    fn project_key(&self) -> &str {
5106        self.project_key()
5107    }
5108    fn sqlite_path(&self) -> &Path {
5109        self.sqlite_path()
5110    }
5111    fn is_current(&self) -> bool {
5112        self.is_current()
5113    }
5114    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5115        self.edge_snapshot()
5116    }
5117    fn indexed_file_count(&self) -> Result<usize> {
5118        self.indexed_file_count()
5119    }
5120    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5121        self.node_for(file_rel, symbol)
5122    }
5123    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5124        self.nodes_for(file_rel, symbol)
5125    }
5126    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5127        self.nodes_matching(symbol)
5128    }
5129    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5130        self.direct_callers_of(file_rel, symbol)
5131    }
5132    fn direct_callers_for_symbols(
5133        &self,
5134        targets: &[(String, String)],
5135    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5136        self.direct_callers_for_symbols(targets)
5137    }
5138    fn direct_caller_counts_of(
5139        &self,
5140        targets: &[(String, String)],
5141    ) -> Result<HashMap<(String, String), usize>> {
5142        self.direct_caller_counts_of(targets)
5143    }
5144    fn callers_of(
5145        &self,
5146        file_rel: &Path,
5147        symbol: &str,
5148        depth: usize,
5149    ) -> Result<StoreCallersResult> {
5150        self.callers_of(file_rel, symbol, depth)
5151    }
5152    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5153        self.impact_of(file_rel, symbol, depth)
5154    }
5155    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5156        self.outgoing_calls_of(node)
5157    }
5158    fn outgoing_calls_for_symbols(
5159        &self,
5160        sources: &[(String, String)],
5161    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5162        self.outgoing_calls_for_symbols(sources)
5163    }
5164    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5165        self.resolved_self_calls_of(node)
5166    }
5167    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5168        self.unresolved_calls_of(node)
5169    }
5170    fn call_tree(
5171        &self,
5172        file_rel: &Path,
5173        symbol: &str,
5174        depth: usize,
5175    ) -> Result<callgraph::CallTreeNode> {
5176        self.call_tree(file_rel, symbol, depth)
5177    }
5178    fn trace_to(
5179        &self,
5180        file_rel: &Path,
5181        symbol: &str,
5182        max_depth: usize,
5183    ) -> Result<callgraph::TraceToResult> {
5184        self.trace_to(file_rel, symbol, max_depth)
5185    }
5186    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5187        self.trace_to_symbol_candidates(to_symbol)
5188    }
5189    fn trace_to_symbol(
5190        &self,
5191        file_rel: &Path,
5192        symbol: &str,
5193        to_symbol: &str,
5194        to_file: Option<&Path>,
5195        max_depth: usize,
5196    ) -> Result<callgraph::TraceToSymbolResult> {
5197        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5198    }
5199}
5200
5201fn indexed_file_count(conn: &Connection) -> Result<usize> {
5202    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5203    Ok(count.max(0) as usize)
5204}
5205
5206fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5207    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5208    match candidates.as_slice() {
5209        [candidate] => Ok(candidate.clone()),
5210        [] => Err(AftError::SymbolNotFound {
5211            name: symbol.to_string(),
5212            file: rel_path.to_string(),
5213        }
5214        .into()),
5215        _ => Err(AftError::AmbiguousSymbol {
5216            name: symbol.to_string(),
5217            candidates: candidates
5218                .iter()
5219                .map(|candidate| candidate.symbol.clone())
5220                .collect(),
5221        }
5222        .into()),
5223    }
5224}
5225
5226fn nodes_for_file_matching_symbol(
5227    conn: &Connection,
5228    rel_path: &str,
5229    symbol: &str,
5230) -> Result<Vec<StoreNode>> {
5231    let qualified_query = symbol.contains("::");
5232    let sql = if qualified_query {
5233        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5234                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5235         FROM nodes n JOIN files f ON f.path = n.file_path
5236         WHERE n.file_path = ?1 AND n.scoped_name = ?2
5237         ORDER BY n.scoped_name, n.start_line, n.start_col"
5238    } else {
5239        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5240                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5241         FROM nodes n JOIN files f ON f.path = n.file_path
5242         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5243         ORDER BY n.scoped_name, n.start_line, n.start_col"
5244    };
5245    let mut stmt = conn.prepare(sql)?;
5246    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5247    rows.collect::<std::result::Result<Vec<_>, _>>()
5248        .map_err(Into::into)
5249}
5250
5251fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5252    let qualified_query = symbol.contains("::");
5253    let sql = if qualified_query {
5254        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5255                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5256         FROM nodes n JOIN files f ON f.path = n.file_path
5257         WHERE n.scoped_name = ?1
5258         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5259    } else {
5260        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5261                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5262         FROM nodes n JOIN files f ON f.path = n.file_path
5263         WHERE n.scoped_name = ?1 OR n.name = ?1
5264         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5265    };
5266    let mut stmt = conn.prepare(sql)?;
5267    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5268    rows.collect::<std::result::Result<Vec<_>, _>>()
5269        .map_err(Into::into)
5270}
5271
5272fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5273    store_node_from_row_at(row, 0)
5274}
5275
5276fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5277    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5278    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5279    let lang_label_value: String = row.get(offset + 10)?;
5280    Ok(StoreNode {
5281        node_id: row.get(offset)?,
5282        file: row.get(offset + 1)?,
5283        symbol: row.get(offset + 2)?,
5284        name: row.get(offset + 3)?,
5285        kind: row.get(offset + 4)?,
5286        line: start_line.saturating_add(1),
5287        end_line: end_line.saturating_add(1),
5288        signature: row.get(offset + 7)?,
5289        exported: row.get::<_, i64>(offset + 8)? != 0,
5290        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5291        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5292    })
5293}
5294
5295fn optional_store_node_from_row_at(
5296    row: &rusqlite::Row<'_>,
5297    offset: usize,
5298) -> rusqlite::Result<Option<StoreNode>> {
5299    if row.get::<_, Option<String>>(offset)?.is_some() {
5300        store_node_from_row_at(row, offset).map(Some)
5301    } else {
5302        Ok(None)
5303    }
5304}
5305
5306#[allow(clippy::too_many_arguments)]
5307fn collect_callers_recursive(
5308    conn: &Connection,
5309    file: &str,
5310    symbol: &str,
5311    max_depth: usize,
5312    current_depth: usize,
5313    visited: &mut HashSet<(String, String)>,
5314    result: &mut Vec<StoreCallSite>,
5315    depth_limited: &mut bool,
5316    truncated: &mut usize,
5317) -> Result<()> {
5318    if current_depth >= max_depth {
5319        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5320        if omitted > 0 {
5321            *depth_limited = true;
5322            *truncated += omitted;
5323        }
5324        return Ok(());
5325    }
5326
5327    if !visited.insert((file.to_string(), symbol.to_string())) {
5328        return Ok(());
5329    }
5330
5331    let sites = direct_callers_for_tuple(conn, file, symbol)?;
5332    for site in sites {
5333        result.push(site.clone());
5334        if current_depth + 1 < max_depth {
5335            collect_callers_recursive(
5336                conn,
5337                &site.caller.file,
5338                &site.caller.symbol,
5339                max_depth,
5340                current_depth + 1,
5341                visited,
5342                result,
5343                depth_limited,
5344                truncated,
5345            )?;
5346        } else {
5347            let omitted =
5348                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5349            if omitted > 0 {
5350                *depth_limited = true;
5351                *truncated += omitted;
5352            }
5353        }
5354    }
5355    Ok(())
5356}
5357
5358// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5359const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5360
5361fn direct_caller_counts_for_tuples(
5362    conn: &Connection,
5363    targets: &[(String, String)],
5364) -> Result<HashMap<(String, String), usize>> {
5365    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5366    let mut counts = unique_targets
5367        .iter()
5368        .cloned()
5369        .map(|target| (target, 0usize))
5370        .collect::<HashMap<_, _>>();
5371
5372    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5373    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5374        let requested_values = (0..chunk.len())
5375            .map(|_| "(?, ?)")
5376            .collect::<Vec<_>>()
5377            .join(", ");
5378        let sql = format!(
5379            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5380             deduped AS (
5381                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5382                 FROM requested requested
5383                 JOIN edges e
5384                   ON e.target_file = requested.target_file
5385                  AND e.target_symbol = requested.target_symbol
5386                  AND e.kind = 'call'
5387                 JOIN refs r ON r.ref_id = e.ref_id
5388                 JOIN nodes src ON src.id = e.source_node
5389                 JOIN files src_file ON src_file.path = src.file_path
5390                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5391             )
5392             SELECT target_file, target_symbol, COUNT(*)
5393             FROM deduped
5394             GROUP BY target_file, target_symbol"
5395        );
5396        let bindings = chunk
5397            .iter()
5398            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5399        let mut stmt = conn.prepare(&sql)?;
5400        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5401            Ok((
5402                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5403                row.get::<_, i64>(2)?,
5404            ))
5405        })?;
5406        for row in rows {
5407            let (target, count) = row?;
5408            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5409        }
5410    }
5411
5412    Ok(counts)
5413}
5414
5415fn direct_caller_count_for_tuple(
5416    conn: &Connection,
5417    target_file: &str,
5418    target_symbol: &str,
5419) -> Result<usize> {
5420    let count: i64 = conn.query_row(
5421        "SELECT COUNT(*)
5422         FROM edges e
5423         JOIN refs r ON r.ref_id = e.ref_id
5424         JOIN nodes src ON src.id = e.source_node
5425         JOIN files src_file ON src_file.path = src.file_path
5426         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5427        params![target_file, target_symbol],
5428        |row| row.get(0),
5429    )?;
5430    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5431}
5432
5433fn direct_callers_for_tuple(
5434    conn: &Connection,
5435    target_file: &str,
5436    target_symbol: &str,
5437) -> Result<Vec<StoreCallSite>> {
5438    let mut stmt = conn.prepare(
5439        "SELECT e.target_file, e.target_symbol, e.line,
5440                r.byte_start, r.byte_end, r.status, e.provenance,
5441                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5442                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5443                src_file.lang,
5444                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5445                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5446                tgt_file.lang
5447         FROM edges e
5448         JOIN refs r ON r.ref_id = e.ref_id
5449         JOIN nodes src ON src.id = e.source_node
5450         JOIN files src_file ON src_file.path = src.file_path
5451         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5452             ON tgt.id = e.target_node
5453         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5454         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5455    )?;
5456    let rows = stmt.query_map(
5457        params![target_file, target_symbol],
5458        direct_call_site_from_row,
5459    )?;
5460    rows.collect::<std::result::Result<Vec<_>, _>>()
5461        .map_err(Into::into)
5462}
5463
5464fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5465    let caller = store_node_from_row_at(row, 7)?;
5466    let target = optional_store_node_from_row_at(row, 18)?;
5467    Ok(StoreCallSite {
5468        caller,
5469        target_file: row.get(0)?,
5470        target_symbol: row.get(1)?,
5471        target,
5472        line: row.get::<_, i64>(2)?.max(0) as u32,
5473        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5474        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5475        resolved: row.get::<_, String>(5)? == "resolved",
5476        provenance: row.get(6)?,
5477    })
5478}
5479
5480fn direct_callers_for_tuples(
5481    conn: &Connection,
5482    targets: &[(String, String)],
5483) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5484    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5485    let mut callers_by_target = unique_targets
5486        .iter()
5487        .cloned()
5488        .map(|target| (target, Vec::new()))
5489        .collect::<HashMap<_, _>>();
5490    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5491
5492    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5493        let requested_values = (0..chunk.len())
5494            .map(|_| "(?, ?)")
5495            .collect::<Vec<_>>()
5496            .join(", ");
5497        let sql = format!(
5498            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5499             SELECT e.target_file, e.target_symbol, e.line,
5500                    r.byte_start, r.byte_end, r.status, e.provenance,
5501                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5502                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5503                    src_file.lang,
5504                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5505                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5506                    tgt_file.lang
5507             FROM requested requested
5508             JOIN edges e
5509               ON e.target_file = requested.target_file
5510              AND e.target_symbol = requested.target_symbol
5511              AND e.kind = 'call'
5512             JOIN refs r ON r.ref_id = e.ref_id
5513             JOIN nodes src ON src.id = e.source_node
5514             JOIN files src_file ON src_file.path = src.file_path
5515             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5516                 ON tgt.id = e.target_node
5517             ORDER BY e.target_file, e.target_symbol, e.source_node,
5518                      r.byte_start, r.line, r.ref_id"
5519        );
5520        let bindings = chunk
5521            .iter()
5522            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5523        let mut stmt = conn.prepare(&sql)?;
5524        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5525            let call = direct_call_site_from_row(row)?;
5526            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5527            Ok((target_key, call))
5528        })?;
5529        for row in rows {
5530            let (target, call) = row?;
5531            callers_by_target
5532                .get_mut(&target)
5533                .expect("batched caller row belongs to a requested target")
5534                .push(call);
5535        }
5536    }
5537
5538    Ok(callers_by_target)
5539}
5540
5541// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5542const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5543// Outgoing-edge batches bind one source node per parameter.
5544const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5545
5546fn outgoing_calls_for_symbol_tuples(
5547    conn: &Connection,
5548    sources: &[(String, String)],
5549) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5550    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5551    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5552    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5553    let source_nodes = unique_sources
5554        .iter()
5555        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5556        .cloned()
5557        .collect::<Vec<_>>();
5558    let source_nodes_by_id = source_nodes
5559        .iter()
5560        .cloned()
5561        .map(|node| (node.node_id.clone(), node))
5562        .collect::<HashMap<_, _>>();
5563    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5564
5565    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5566        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5567        let sql = format!(
5568            "SELECT e.source_node,
5569                    e.target_file, e.target_symbol, e.line,
5570                    r.byte_start, r.byte_end, r.status, e.provenance,
5571                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5572                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5573                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5574                    tgt_file.lang
5575             FROM edges e
5576             JOIN refs r ON r.ref_id = e.ref_id
5577             LEFT JOIN nodes tgt ON tgt.id = e.target_node
5578             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5579             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5580             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5581        );
5582        let bindings = chunk.iter().map(|node| node.node_id.as_str());
5583        let mut stmt = conn.prepare(&sql)?;
5584        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5585            let source_node_id = row.get::<_, String>(0)?;
5586            let caller = source_nodes_by_id
5587                .get(&source_node_id)
5588                .expect("batched outgoing row belongs to a requested source node")
5589                .clone();
5590            let target = optional_store_node_from_row_at(row, 8)?;
5591            Ok((
5592                source_node_id,
5593                StoreCallSite {
5594                    caller,
5595                    target_file: row.get(1)?,
5596                    target_symbol: row.get(2)?,
5597                    target,
5598                    line: row.get::<_, i64>(3)?.max(0) as u32,
5599                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
5600                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
5601                    resolved: row.get::<_, String>(6)? == "resolved",
5602                    provenance: row.get(7)?,
5603                },
5604            ))
5605        })?;
5606        for row in rows {
5607            let (source_node_id, call) = row?;
5608            calls_by_node.entry(source_node_id).or_default().push(call);
5609        }
5610    }
5611
5612    let mut calls_by_source = HashMap::new();
5613    for source in &unique_sources {
5614        let mut calls = Vec::new();
5615        if let Some(nodes) = source_nodes_by_symbol.get(source) {
5616            for node in nodes {
5617                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
5618                    calls.extend(node_calls);
5619                }
5620            }
5621        }
5622        calls_by_source.insert(source.clone(), calls);
5623    }
5624
5625    // Resolve each logical target once for the whole frontier. Keeping this separate
5626    // preserves positional-symbol representatives without a correlated lookup per edge.
5627    let target_tuples = calls_by_source
5628        .values()
5629        .flatten()
5630        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
5631        .collect::<Vec<_>>();
5632    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
5633    for calls in calls_by_source.values_mut() {
5634        for call in calls {
5635            if let Some(target) = target_nodes
5636                .get(&(call.target_file.clone(), call.target_symbol.clone()))
5637                .and_then(|nodes| nodes.first())
5638            {
5639                call.target = Some(target.clone());
5640            }
5641        }
5642    }
5643
5644    Ok(calls_by_source)
5645}
5646
5647fn nodes_for_symbol_tuples(
5648    conn: &Connection,
5649    symbols: &[(String, String)],
5650) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
5651    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
5652    let mut nodes_by_symbol = unique_symbols
5653        .iter()
5654        .cloned()
5655        .map(|symbol| (symbol, Vec::new()))
5656        .collect::<HashMap<_, _>>();
5657    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
5658
5659    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
5660        let requested_values = (0..chunk.len())
5661            .map(|_| "(?, ?)")
5662            .collect::<Vec<_>>()
5663            .join(", ");
5664        let sql = format!(
5665            "WITH requested(file, symbol) AS (VALUES {requested_values})
5666             SELECT requested.file, requested.symbol,
5667                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
5668                    node.start_line, node.end_line, node.signature, node.exported,
5669                    node.is_callgraph_entry_point, node_file.lang
5670             FROM requested
5671             JOIN nodes node INDEXED BY idx_nodes_file
5672               ON node.file_path = requested.file
5673              AND node.scoped_name = requested.symbol
5674             JOIN files node_file ON node_file.path = node.file_path
5675             ORDER BY requested.file, requested.symbol,
5676                      node.scoped_name, node.start_line, node.end_line,
5677                      node.start_col, node.range_ordinal"
5678        );
5679        let bindings = chunk
5680            .iter()
5681            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5682        let mut stmt = conn.prepare(&sql)?;
5683        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5684            Ok((
5685                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5686                store_node_from_row_at(row, 2)?,
5687            ))
5688        })?;
5689        for row in rows {
5690            let (symbol, node) = row?;
5691            nodes_by_symbol.entry(symbol).or_default().push(node);
5692        }
5693    }
5694
5695    Ok(nodes_by_symbol)
5696}
5697
5698fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5699    let mut stmt = conn.prepare(
5700        "SELECT e.target_file, e.target_symbol, e.line,
5701                r.byte_start, r.byte_end, r.status, e.provenance,
5702                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5703                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5704                tgt_file.lang
5705         FROM edges e
5706         JOIN refs r ON r.ref_id = e.ref_id
5707         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5708             ON tgt.id = e.target_node
5709         WHERE e.kind = 'call' AND e.source_node = ?1
5710         ORDER BY r.byte_start, r.line, r.ref_id",
5711    )?;
5712    let rows = stmt.query_map(params![node.node_id], |row| {
5713        let target = optional_store_node_from_row_at(row, 7)?;
5714        Ok(StoreCallSite {
5715            caller: node.clone(),
5716            target_file: row.get(0)?,
5717            target_symbol: row.get(1)?,
5718            target,
5719            line: row.get::<_, i64>(2)?.max(0) as u32,
5720            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5721            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5722            resolved: row.get::<_, String>(5)? == "resolved",
5723            provenance: row.get(6)?,
5724        })
5725    })?;
5726    rows.collect::<std::result::Result<Vec<_>, _>>()
5727        .map_err(Into::into)
5728}
5729
5730fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5731    let mut stmt = conn.prepare(
5732        "SELECT r.target_file, r.target_symbol, r.line,
5733                r.byte_start, r.byte_end, r.status, r.provenance,
5734                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5735                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5736                tgt_file.lang
5737         FROM refs r
5738         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5739             ON tgt.id = r.target_node
5740         WHERE r.caller_node = ?1
5741           AND r.kind = 'call'
5742           AND r.status <> 'unresolved'
5743           AND r.target_file = ?2
5744           AND r.target_symbol = ?3
5745           AND r.provenance = ?4
5746           AND NOT EXISTS (
5747               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
5748           )
5749         ORDER BY r.byte_start, r.line, r.ref_id",
5750    )?;
5751    let rows = stmt.query_map(
5752        params![
5753            &node.node_id,
5754            &node.file,
5755            &node.symbol,
5756            PROVENANCE_TREESITTER
5757        ],
5758        |row| {
5759            let target = optional_store_node_from_row_at(row, 7)?;
5760            Ok(StoreCallSite {
5761                caller: node.clone(),
5762                target_file: row.get(0)?,
5763                target_symbol: row.get(1)?,
5764                target,
5765                line: row.get::<_, i64>(2)?.max(0) as u32,
5766                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5767                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5768                resolved: row.get::<_, String>(5)? == "resolved",
5769                provenance: row.get(6)?,
5770            })
5771        },
5772    )?;
5773    rows.collect::<std::result::Result<Vec<_>, _>>()
5774        .map_err(Into::into)
5775}
5776
5777fn unresolved_calls_for_node(
5778    conn: &Connection,
5779    node: &StoreNode,
5780) -> Result<Vec<StoreUnresolvedCall>> {
5781    let mut stmt = conn.prepare(
5782        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
5783         FROM refs
5784         WHERE caller_node = ?1
5785           AND kind = 'call'
5786           AND status = 'unresolved'
5787           AND NOT EXISTS (
5788               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5789           )
5790         ORDER BY byte_start, line, ref_id",
5791    )?;
5792    let rows = stmt.query_map(params![node.node_id], |row| {
5793        Ok(StoreUnresolvedCall {
5794            caller: node.clone(),
5795            symbol: row.get(0)?,
5796            full_ref: row.get(1)?,
5797            line: row.get::<_, i64>(2)?.max(0) as u32,
5798            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5799            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5800        })
5801    })?;
5802    rows.collect::<std::result::Result<Vec<_>, _>>()
5803        .map_err(Into::into)
5804}
5805
5806fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
5807    let mut calls = Vec::new();
5808    calls.extend(
5809        outgoing_calls_for_node(conn, node)?
5810            .into_iter()
5811            .map(StoreForwardCall::Resolved),
5812    );
5813    calls.extend(
5814        unresolved_calls_for_node(conn, node)?
5815            .into_iter()
5816            .map(StoreForwardCall::Unresolved),
5817    );
5818    calls.sort_by(|left, right| {
5819        left.byte_start()
5820            .cmp(&right.byte_start())
5821            .then(left.line().cmp(&right.line()))
5822    });
5823    Ok(calls)
5824}
5825
5826fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
5827    let resolved_count: i64 = conn.query_row(
5828        "SELECT COUNT(*)
5829         FROM edges e
5830         JOIN refs r ON r.ref_id = e.ref_id
5831         WHERE e.kind = 'call' AND e.source_node = ?1",
5832        params![&node.node_id],
5833        |row| row.get(0),
5834    )?;
5835    let unresolved_count: i64 = conn.query_row(
5836        "SELECT COUNT(*)
5837         FROM refs
5838         WHERE caller_node = ?1
5839           AND kind = 'call'
5840           AND status = 'unresolved'
5841           AND NOT EXISTS (
5842               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
5843           )",
5844        params![&node.node_id],
5845        |row| row.get(0),
5846    )?;
5847    let total = resolved_count.saturating_add(unresolved_count);
5848    Ok(usize::try_from(total).unwrap_or(usize::MAX))
5849}
5850
5851fn call_tree_inner(
5852    conn: &Connection,
5853    node: &StoreNode,
5854    max_depth: usize,
5855    current_depth: usize,
5856    visited: &mut HashSet<(String, String)>,
5857) -> Result<callgraph::CallTreeNode> {
5858    let visit_key = (node.file.clone(), node.symbol.clone());
5859    if visited.contains(&visit_key) {
5860        return Ok(callgraph::CallTreeNode {
5861            name: node.symbol.clone(),
5862            file: node.file.clone(),
5863            line: node.line,
5864            signature: node.signature.clone(),
5865            resolved: true,
5866            children: Vec::new(),
5867            depth_limited: false,
5868            truncated: 0,
5869        });
5870    }
5871    visited.insert(visit_key.clone());
5872
5873    let mut children = Vec::new();
5874    let mut depth_limited = false;
5875    let mut truncated = 0usize;
5876
5877    if current_depth < max_depth {
5878        let calls = forward_calls_for_node(conn, node)?;
5879        for call in calls {
5880            match call {
5881                StoreForwardCall::Resolved(site) => {
5882                    if let Some(target) = site.target {
5883                        let child =
5884                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
5885                        depth_limited |= child.depth_limited;
5886                        truncated += child.truncated;
5887                        children.push(child);
5888                    } else {
5889                        children.push(callgraph::CallTreeNode {
5890                            name: site.target_symbol,
5891                            file: site.target_file,
5892                            line: site.line,
5893                            signature: None,
5894                            resolved: false,
5895                            children: Vec::new(),
5896                            depth_limited: false,
5897                            truncated: 0,
5898                        });
5899                    }
5900                }
5901                StoreForwardCall::Unresolved(call) => {
5902                    children.push(callgraph::CallTreeNode {
5903                        name: call.symbol,
5904                        file: call.caller.file,
5905                        line: call.line,
5906                        signature: None,
5907                        resolved: false,
5908                        children: Vec::new(),
5909                        depth_limited: false,
5910                        truncated: 0,
5911                    });
5912                }
5913            }
5914        }
5915    } else {
5916        truncated = forward_call_count_for_node(conn, node)?;
5917        depth_limited = truncated > 0;
5918    }
5919
5920    visited.remove(&visit_key);
5921    Ok(callgraph::CallTreeNode {
5922        name: node.symbol.clone(),
5923        file: node.file.clone(),
5924        line: node.line,
5925        signature: node.signature.clone(),
5926        resolved: true,
5927        children,
5928        depth_limited,
5929        truncated,
5930    })
5931}
5932
5933fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
5934    callgraph::TraceToSymbolHop {
5935        symbol: node.symbol.clone(),
5936        file: node.file.clone(),
5937        line: node.line,
5938    }
5939}
5940
5941fn trace_to_symbol_matches_target(
5942    node: &StoreNode,
5943    to_symbol: &str,
5944    to_file: Option<&str>,
5945) -> bool {
5946    if !symbol_query_matches(&node.symbol, to_symbol) {
5947        return false;
5948    }
5949    match to_file {
5950        Some(file) => node.file == file,
5951        None => true,
5952    }
5953}
5954
5955fn symbol_query_matches(symbol: &str, query: &str) -> bool {
5956    symbol == query || unqualified_name(symbol) == query
5957}
5958
5959fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
5960    let source = std::fs::read_to_string(path).ok()?;
5961    Some(source.lines().map(|line| line.trim().to_string()).collect())
5962}
5963
5964#[doc(hidden)]
5965pub fn live_callgraph_edge_snapshot(
5966    project_root: &Path,
5967    files: &[PathBuf],
5968) -> Result<BTreeSet<StoredEdge>> {
5969    let files = normalize_file_list(project_root, files)?;
5970    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
5971    let mut file_data = Vec::new();
5972    for file in &files {
5973        let canon = canonicalize_path(file);
5974        let data = graph.build_file(&canon)?.clone();
5975        file_data.push((canon, data));
5976    }
5977
5978    let mut edges = BTreeSet::new();
5979    for (caller_file, data) in &file_data {
5980        for (caller_symbol, call_sites) in &data.calls_by_symbol {
5981            for call_site in call_sites {
5982                let resolution = graph.resolve_cross_file_edge(
5983                    &call_site.full_callee,
5984                    &call_site.callee_name,
5985                    caller_file,
5986                    &data.import_block,
5987                );
5988                let (target_file, target_symbol) = match resolution {
5989                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
5990                    EdgeResolution::Unresolved { callee_name } => {
5991                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
5992                            continue;
5993                        }
5994                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
5995                            data,
5996                            caller_file,
5997                            &callee_name,
5998                        ) else {
5999                            continue;
6000                        };
6001                        (caller_file.clone(), target_symbol)
6002                    }
6003                };
6004                if target_file == *caller_file && target_symbol == *caller_symbol {
6005                    continue;
6006                }
6007                edges.insert(StoredEdge {
6008                    source_file: relative_path(project_root, caller_file),
6009                    source_symbol: caller_symbol.clone(),
6010                    target_file: relative_path(project_root, &target_file),
6011                    target_symbol,
6012                    kind: "call".to_string(),
6013                    line: call_site.line,
6014                });
6015            }
6016        }
6017    }
6018    Ok(edges)
6019}
6020
6021fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
6022{
6023    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
6024}
6025
6026fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
6027    RebuildCooldownKey {
6028        callgraph_dir: std::fs::canonicalize(callgraph_dir)
6029            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
6030        project_key: project_key.to_string(),
6031    }
6032}
6033
6034fn rebuild_cooldown_denial(
6035    callgraph_dir: &Path,
6036    project_key: &str,
6037    project_root: &Path,
6038    now: Instant,
6039) -> Option<(PathBuf, Duration)> {
6040    let key = rebuild_cooldown_key(callgraph_dir, project_key);
6041    let records = rebuild_cooldown_records()
6042        .lock()
6043        .unwrap_or_else(std::sync::PoisonError::into_inner);
6044    let record = records.get(&key)?;
6045    if record.project_root == project_root || !record.cross_root_cooldown_armed {
6046        return None;
6047    }
6048    let elapsed = now.saturating_duration_since(record.published_at);
6049    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
6050}
6051
6052fn record_successful_rebuild(
6053    callgraph_dir: &Path,
6054    project_key: &str,
6055    project_root: &Path,
6056    published_at: Instant,
6057) {
6058    let key = rebuild_cooldown_key(callgraph_dir, project_key);
6059    let mut records = rebuild_cooldown_records()
6060        .lock()
6061        .unwrap_or_else(std::sync::PoisonError::into_inner);
6062    if records.len() >= 4_096 && !records.contains_key(&key) {
6063        if let Some(evict) = records.keys().next().cloned() {
6064            records.remove(&evict);
6065        }
6066    }
6067    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
6068        previous.cross_root_cooldown_armed || previous.project_root != project_root
6069    });
6070    records.insert(
6071        key,
6072        RebuildCooldownRecord {
6073            project_root: project_root.to_path_buf(),
6074            published_at,
6075            cross_root_cooldown_armed,
6076        },
6077    );
6078}
6079
6080fn acquire_writer_lease(
6081    callgraph_dir: &Path,
6082    project_key: &str,
6083    project_root: &Path,
6084) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6085    crate::root_cache::WriterLease::acquire_shared(
6086        crate::root_cache::RootCacheDomain::Callgraph,
6087        callgraph_dir,
6088        project_key,
6089        project_root,
6090    )
6091    .map_err(CallGraphStoreError::from)
6092}
6093
6094fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6095    if lease.verify()? {
6096        Ok(())
6097    } else {
6098        Err(CallGraphStoreError::Unavailable(format!(
6099            "callgraph writer lease for key {} lost epoch {}; aborting write",
6100            lease.key(),
6101            lease.epoch()
6102        )))
6103    }
6104}
6105
6106fn legacy_migration_completion_line(
6107    project_key: &str,
6108    method: &str,
6109    legacy_bytes: u64,
6110    migrated_bytes: u64,
6111) -> String {
6112    format!(
6113        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6114    )
6115}
6116
6117fn log_legacy_migration_completion(
6118    project_key: &str,
6119    method: &str,
6120    legacy_bytes: u64,
6121    migrated_bytes: u64,
6122) {
6123    crate::slog_info!(
6124        "{}",
6125        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6126    );
6127}
6128
6129fn try_legacy_migration_or_fallback(
6130    callgraph_dir: &Path,
6131    project_root: &Path,
6132    project_key: &str,
6133    writer_lease: Arc<crate::root_cache::WriterLease>,
6134) -> Result<Option<CallGraphStore>> {
6135    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6136    if partitions.is_empty() {
6137        return Ok(None);
6138    }
6139
6140    for partition in &partitions {
6141        if let Some(source) = newest_superseded_legacy_generation(partition)? {
6142            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6143                return open_legacy_fallback_store(
6144                    callgraph_dir,
6145                    project_root,
6146                    project_key,
6147                    &partitions,
6148                );
6149            }
6150            match publish_generation_copy_migration(
6151                callgraph_dir,
6152                project_key,
6153                &source,
6154                Arc::clone(&writer_lease),
6155            ) {
6156                Ok(published) => {
6157                    log_legacy_migration_completion(
6158                        project_key,
6159                        "generation_copy",
6160                        source.source_bytes,
6161                        published.migrated_bytes,
6162                    );
6163                    return CallGraphStore::open_generation(
6164                        callgraph_dir,
6165                        project_root.to_path_buf(),
6166                        project_key.to_string(),
6167                        published.generation,
6168                        writer_lease,
6169                    )
6170                    .map(Some);
6171                }
6172                Err(error) => {
6173                    crate::slog_warn!(
6174                        "root-keyed callgraph generation-copy migration failed from {}: {}",
6175                        source.sqlite_path.display(),
6176                        error
6177                    );
6178                    return open_legacy_fallback_store(
6179                        callgraph_dir,
6180                        project_root,
6181                        project_key,
6182                        &partitions,
6183                    );
6184                }
6185            }
6186        }
6187
6188        if let Some(source) = current_legacy_generation(partition)? {
6189            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6190                return open_legacy_fallback_store(
6191                    callgraph_dir,
6192                    project_root,
6193                    project_key,
6194                    &partitions,
6195                );
6196            }
6197            match publish_backup_migration(
6198                callgraph_dir,
6199                project_key,
6200                &source,
6201                Arc::clone(&writer_lease),
6202            ) {
6203                Ok(published) => {
6204                    log_legacy_migration_completion(
6205                        project_key,
6206                        "sqlite_backup",
6207                        source.source_bytes,
6208                        published.migrated_bytes,
6209                    );
6210                    return CallGraphStore::open_generation(
6211                        callgraph_dir,
6212                        project_root.to_path_buf(),
6213                        project_key.to_string(),
6214                        published.generation,
6215                        writer_lease,
6216                    )
6217                    .map(Some);
6218                }
6219                Err(error) => {
6220                    crate::slog_warn!(
6221                        "root-keyed callgraph backup migration failed from {}: {}",
6222                        source.sqlite_path.display(),
6223                        error
6224                    );
6225                    return open_legacy_fallback_store(
6226                        callgraph_dir,
6227                        project_root,
6228                        project_key,
6229                        &partitions,
6230                    );
6231                }
6232            }
6233        }
6234    }
6235
6236    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6237}
6238
6239fn open_legacy_fallback_store(
6240    callgraph_dir: &Path,
6241    project_root: &Path,
6242    project_key: &str,
6243    partitions: &[LegacyCallgraphPartition],
6244) -> Result<Option<CallGraphStore>> {
6245    let Some(target) = first_ready_legacy_target(partitions)? else {
6246        return Ok(None);
6247    };
6248    crate::slog_warn!(
6249        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6250        target.partition.harness,
6251        target.sqlite_path.display()
6252    );
6253    let conn = open_readonly_connection(&target.sqlite_path)?;
6254    if !database_ready(&conn).unwrap_or(false) {
6255        return Ok(None);
6256    }
6257    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6258    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6259    Ok(Some(CallGraphStore::from_connection(
6260        project_root.to_path_buf(),
6261        project_key.to_string(),
6262        target.sqlite_path,
6263        callgraph_dir.to_path_buf(),
6264        true,
6265        target.generation,
6266        None,
6267        Some(read_marker),
6268        conn,
6269    )))
6270}
6271
6272fn migration_disk_floor_allows(
6273    source: &LegacyCallgraphTarget,
6274    callgraph_dir: &Path,
6275) -> Result<bool> {
6276    let available = migration_available_disk(callgraph_dir)?;
6277    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6278        source.source_bytes,
6279        available,
6280    );
6281    if decision.should_skip_copy() {
6282        crate::slog_warn!(
6283            "{}",
6284            decision.warning_message(&source.sqlite_path, callgraph_dir)
6285        );
6286        return Ok(false);
6287    }
6288    Ok(true)
6289}
6290
6291fn migration_available_disk(path: &Path) -> Result<u64> {
6292    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6293        return Ok(bytes);
6294    }
6295    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6296}
6297
6298fn legacy_callgraph_partitions(
6299    callgraph_dir: &Path,
6300    project_key: &str,
6301) -> Result<Vec<LegacyCallgraphPartition>> {
6302    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6303        return Ok(Vec::new());
6304    };
6305    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6306    let mut partitions = inventory
6307        .into_iter()
6308        .filter(|entry| {
6309            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6310                && entry.key == project_key
6311        })
6312        .map(|entry| {
6313            let dir = if entry.path.is_dir() {
6314                entry.path.clone()
6315            } else {
6316                entry
6317                    .path
6318                    .parent()
6319                    .map(Path::to_path_buf)
6320                    .unwrap_or_else(|| entry.path.clone())
6321            };
6322            LegacyCallgraphPartition {
6323                harness: entry.harness,
6324                dir,
6325                key: entry.key,
6326                bytes: entry.bytes,
6327                freshness: entry.callgraph_pointer_mtime,
6328            }
6329        })
6330        .collect::<Vec<_>>();
6331    partitions.sort_by(|left, right| {
6332        right
6333            .freshness
6334            .cmp(&left.freshness)
6335            .then_with(|| right.bytes.cmp(&left.bytes))
6336            .then_with(|| left.harness.cmp(&right.harness))
6337    });
6338    Ok(partitions)
6339}
6340
6341fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6342    let domain_dir = callgraph_dir.parent()?;
6343    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6344        return None;
6345    }
6346    domain_dir.parent().map(Path::to_path_buf)
6347}
6348
6349pub(crate) fn all_legacy_partitions_migrated_for_keys(
6350    callgraph_dir: &Path,
6351    configured_keys: &BTreeSet<String>,
6352) -> Result<bool> {
6353    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6354        return Ok(false);
6355    };
6356    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6357        .into_iter()
6358        .filter(|entry| {
6359            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6360                && configured_keys.contains(&entry.key)
6361        })
6362        .map(|entry| entry.key)
6363        .collect::<BTreeSet<_>>();
6364    if legacy_keys.is_empty() {
6365        return Ok(false);
6366    }
6367
6368    for key in legacy_keys {
6369        let migrated_dir = storage_root.join("callgraph").join(&key);
6370        let Some(generation) = read_pointer(&migrated_dir, &key) else {
6371            return Ok(false);
6372        };
6373        if !migration_generation_requires_manifest(&generation)
6374            || !migration_manifest_valid(&migrated_dir, &generation)
6375        {
6376            return Ok(false);
6377        }
6378    }
6379    Ok(true)
6380}
6381
6382fn newest_superseded_legacy_generation(
6383    partition: &LegacyCallgraphPartition,
6384) -> Result<Option<LegacyCallgraphTarget>> {
6385    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6386        return Ok(None);
6387    };
6388    let prefix = format!("{}.g", partition.key);
6389    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6390        return Ok(None);
6391    };
6392    let mut candidates = Vec::new();
6393    for entry in entries.flatten() {
6394        let name = entry.file_name().to_string_lossy().to_string();
6395        if name == current
6396            || name.contains(".tmp.")
6397            || !name.starts_with(&prefix)
6398            || !name.ends_with(".sqlite")
6399        {
6400            continue;
6401        }
6402        let path = entry.path();
6403        if !db_path_ready(&path) {
6404            continue;
6405        }
6406        let modified = entry
6407            .metadata()
6408            .and_then(|metadata| metadata.modified())
6409            .unwrap_or(SystemTime::UNIX_EPOCH);
6410        candidates.push((modified, path, name));
6411    }
6412    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6413    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6414        return Ok(None);
6415    };
6416    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6417    Ok(Some(LegacyCallgraphTarget {
6418        partition: partition.clone(),
6419        sqlite_path,
6420        generation: Some(generation),
6421        source_bytes,
6422        source_blake3: String::new(),
6423    }))
6424}
6425
6426fn current_legacy_generation(
6427    partition: &LegacyCallgraphPartition,
6428) -> Result<Option<LegacyCallgraphTarget>> {
6429    let Some(target) = ready_legacy_target(partition)? else {
6430        return Ok(None);
6431    };
6432    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6433    if has_superseded {
6434        return Ok(None);
6435    }
6436    Ok(Some(target))
6437}
6438
6439fn freshest_legacy_fallback_target(
6440    callgraph_dir: &Path,
6441    project_key: &str,
6442) -> Result<Option<LegacyCallgraphTarget>> {
6443    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6444    first_ready_legacy_target(&partitions)
6445}
6446
6447fn first_ready_legacy_target(
6448    partitions: &[LegacyCallgraphPartition],
6449) -> Result<Option<LegacyCallgraphTarget>> {
6450    for partition in partitions {
6451        if let Some(target) = ready_legacy_target(partition)? {
6452            return Ok(Some(target));
6453        }
6454    }
6455    Ok(None)
6456}
6457
6458fn ready_legacy_target(
6459    partition: &LegacyCallgraphPartition,
6460) -> Result<Option<LegacyCallgraphTarget>> {
6461    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6462        let sqlite_path = partition.dir.join(&generation);
6463        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6464            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6465            return Ok(Some(LegacyCallgraphTarget {
6466                partition: partition.clone(),
6467                sqlite_path,
6468                generation: Some(generation),
6469                source_bytes,
6470                source_blake3: String::new(),
6471            }));
6472        }
6473    }
6474
6475    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6476    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6477        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6478        return Ok(Some(LegacyCallgraphTarget {
6479            partition: partition.clone(),
6480            sqlite_path,
6481            generation: None,
6482            source_bytes,
6483            source_blake3: String::new(),
6484        }));
6485    }
6486    Ok(None)
6487}
6488
6489fn publish_generation_copy_migration(
6490    callgraph_dir: &Path,
6491    project_key: &str,
6492    source: &LegacyCallgraphTarget,
6493    writer_lease: Arc<crate::root_cache::WriterLease>,
6494) -> Result<PublishedLegacyMigration> {
6495    let generation = migration_generation_file_name(project_key, "copy");
6496    let temp_path = migration_temp_path(callgraph_dir, &generation);
6497    remove_sqlite_file_set(&temp_path);
6498    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6499    fail_after_temp_copy_for_test()?;
6500
6501    let mut source = source.clone();
6502    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6503    source.source_blake3 = fingerprint.blake3;
6504    let generation = publish_migrated_generation(
6505        callgraph_dir,
6506        project_key,
6507        &generation,
6508        &temp_path,
6509        &source,
6510        fingerprint.bytes,
6511        writer_lease,
6512        "generation_copy",
6513    )?;
6514    Ok(PublishedLegacyMigration {
6515        generation,
6516        migrated_bytes: fingerprint.bytes,
6517    })
6518}
6519
6520fn publish_backup_migration(
6521    callgraph_dir: &Path,
6522    project_key: &str,
6523    source: &LegacyCallgraphTarget,
6524    writer_lease: Arc<crate::root_cache::WriterLease>,
6525) -> Result<PublishedLegacyMigration> {
6526    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6527        return Err(CallGraphStoreError::Unavailable(
6528            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6529        ));
6530    }
6531
6532    let generation = migration_generation_file_name(project_key, "backup");
6533    let temp_path = migration_temp_path(callgraph_dir, &generation);
6534    remove_sqlite_file_set(&temp_path);
6535
6536    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6537    let mut destination = Connection::open(&temp_path)?;
6538    destination.busy_timeout(Duration::from_secs(5))?;
6539    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6540    let started = Instant::now();
6541    let mut retries = 0;
6542    loop {
6543        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6544            rusqlite::backup::StepResult::Done => break,
6545            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6546            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6547                retries += 1;
6548                if retries > MIGRATION_BACKUP_RETRY_BUDGET
6549                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6550                {
6551                    return Err(CallGraphStoreError::Unavailable(format!(
6552                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6553                    )));
6554                }
6555                std::thread::sleep(Duration::from_millis(20));
6556            }
6557            _ => {
6558                return Err(CallGraphStoreError::Unavailable(
6559                    "legacy callgraph backup returned an unknown step result".to_string(),
6560                ));
6561            }
6562        }
6563    }
6564    drop(backup);
6565
6566    let integrity: String =
6567        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6568    if integrity != "ok" {
6569        return Err(CallGraphStoreError::Unavailable(format!(
6570            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6571        )));
6572    }
6573    if !database_ready(&destination)? {
6574        return Err(CallGraphStoreError::Unavailable(
6575            "legacy callgraph backup produced a database without ready metadata".to_string(),
6576        ));
6577    }
6578    destination.execute_batch("PRAGMA optimize;")?;
6579    drop(destination);
6580    sync_file(&temp_path)?;
6581    fail_after_temp_copy_for_test()?;
6582
6583    let mut source = source.clone();
6584    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6585    source.source_blake3 = fingerprint.blake3;
6586    let generation = publish_migrated_generation(
6587        callgraph_dir,
6588        project_key,
6589        &generation,
6590        &temp_path,
6591        &source,
6592        fingerprint.bytes,
6593        writer_lease,
6594        "sqlite_backup",
6595    )?;
6596    Ok(PublishedLegacyMigration {
6597        generation,
6598        migrated_bytes: fingerprint.bytes,
6599    })
6600}
6601
6602fn publish_migrated_generation(
6603    callgraph_dir: &Path,
6604    project_key: &str,
6605    generation: &str,
6606    temp_path: &Path,
6607    source: &LegacyCallgraphTarget,
6608    migrated_bytes: u64,
6609    writer_lease: Arc<crate::root_cache::WriterLease>,
6610    method: &str,
6611) -> Result<String> {
6612    let gen_path = callgraph_dir.join(generation);
6613    checkpoint_sqlite_before_publication(temp_path);
6614    let publication = publish_if_current(|| {
6615        verify_writer_lease(&writer_lease)?;
6616        remove_sqlite_file_set(&gen_path);
6617        rename_sqlite_file_set(temp_path, &gen_path)?;
6618        crate::fs_lock::sync_parent(&gen_path);
6619
6620        verify_writer_lease(&writer_lease)?;
6621        publish_pointer(callgraph_dir, project_key, generation)?;
6622        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
6623        Ok(generation.to_string())
6624    });
6625    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
6626        remove_sqlite_file_set(temp_path);
6627    }
6628    publication
6629}
6630
6631fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6632    if let Some(parent) = destination.parent() {
6633        std::fs::create_dir_all(parent)?;
6634    }
6635    for suffix in SQLITE_FILE_SET_SUFFIXES {
6636        let source_path = sqlite_file_set_path(source, suffix);
6637        if !source_path.is_file() {
6638            continue;
6639        }
6640        let destination_path = sqlite_file_set_path(destination, suffix);
6641        std::fs::copy(&source_path, &destination_path)?;
6642        sync_file(&destination_path)?;
6643    }
6644    Ok(())
6645}
6646
6647fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
6648    for suffix in SQLITE_FILE_SET_SUFFIXES {
6649        let source_path = sqlite_file_set_path(source, suffix);
6650        if !source_path.exists() {
6651            continue;
6652        }
6653        let destination_path = sqlite_file_set_path(destination, suffix);
6654        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
6655            let _ = std::fs::remove_file(&source_path);
6656            return Err(error.into());
6657        }
6658    }
6659    Ok(())
6660}
6661
6662fn sqlite_file_set_size(path: &Path) -> Result<u64> {
6663    let mut bytes = 0_u64;
6664    for suffix in SQLITE_FILE_SET_SUFFIXES {
6665        let member = sqlite_file_set_path(path, suffix);
6666        if !member.is_file() {
6667            continue;
6668        }
6669        bytes = bytes.saturating_add(member.metadata()?.len());
6670    }
6671    Ok(bytes)
6672}
6673
6674fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
6675    let mut hasher = blake3::Hasher::new();
6676    let mut bytes = 0_u64;
6677    let mut buffer = [0_u8; 64 * 1024];
6678    for suffix in SQLITE_FILE_SET_SUFFIXES {
6679        let member = sqlite_file_set_path(path, suffix);
6680        if !member.is_file() {
6681            continue;
6682        }
6683        hasher.update(suffix.as_bytes());
6684        let mut file = std::fs::File::open(&member)?;
6685        loop {
6686            let read = file.read(&mut buffer)?;
6687            if read == 0 {
6688                break;
6689            }
6690            bytes = bytes.saturating_add(read as u64);
6691            hasher.update(&buffer[..read]);
6692        }
6693    }
6694    Ok(SourceFingerprint {
6695        bytes,
6696        blake3: hash_to_hex(hasher.finalize()),
6697    })
6698}
6699
6700fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
6701    if suffix.is_empty() {
6702        path.to_path_buf()
6703    } else {
6704        PathBuf::from(format!("{}{suffix}", path.display()))
6705    }
6706}
6707
6708fn sync_file(path: &Path) -> Result<()> {
6709    let file = std::fs::OpenOptions::new()
6710        .read(true)
6711        .write(true)
6712        .open(path)?;
6713    file.sync_all()?;
6714    Ok(())
6715}
6716
6717fn fail_after_temp_copy_for_test() -> Result<()> {
6718    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
6719        return Err(CallGraphStoreError::Unavailable(
6720            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
6721        ));
6722    }
6723    Ok(())
6724}
6725
6726fn migration_generation_file_name(project_key: &str, method: &str) -> String {
6727    format!(
6728        "{project_key}.g{}.{}{}{}.sqlite",
6729        now_nanos(),
6730        std::process::id(),
6731        MIGRATION_GENERATION_TAG,
6732        method
6733    )
6734}
6735
6736fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6737    callgraph_dir.join(format!(
6738        "{generation}.tmp.{}.{}",
6739        std::process::id(),
6740        now_nanos()
6741    ))
6742}
6743
6744fn write_migration_manifest(
6745    callgraph_dir: &Path,
6746    generation: &str,
6747    source: &LegacyCallgraphTarget,
6748    migrated_bytes: u64,
6749    method: &str,
6750) -> Result<()> {
6751    let manifest_path = migration_manifest_path(callgraph_dir, generation);
6752    let temp_path = manifest_path.with_extension(format!(
6753        "migration.json.tmp.{}.{}",
6754        std::process::id(),
6755        now_nanos()
6756    ));
6757    let manifest = serde_json::json!({
6758        "version": MIGRATION_MANIFEST_VERSION,
6759        "method": method,
6760        "target_generation": generation,
6761        "source_harness": source.partition.harness,
6762        "source_path": source.sqlite_path.display().to_string(),
6763        "source_generation": source.generation,
6764        "source_bytes": source.source_bytes,
6765        "source_blake3": source.source_blake3,
6766        "migrated_bytes": migrated_bytes,
6767    });
6768    {
6769        use std::io::Write as _;
6770        let mut file = std::fs::File::create(&temp_path)?;
6771        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
6772        file.write_all(b"\n")?;
6773        file.sync_all()?;
6774    }
6775    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
6776        let _ = std::fs::remove_file(&temp_path);
6777        return Err(error.into());
6778    }
6779    crate::fs_lock::sync_parent(&manifest_path);
6780    Ok(())
6781}
6782
6783fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
6784    callgraph_dir.join(format!("{generation}.migration.json"))
6785}
6786
6787fn migration_generation_requires_manifest(generation: &str) -> bool {
6788    generation.contains(MIGRATION_GENERATION_TAG)
6789}
6790
6791fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
6792    if !migration_generation_requires_manifest(generation) {
6793        return true;
6794    }
6795    let path = migration_manifest_path(callgraph_dir, generation);
6796    let Ok(bytes) = std::fs::read(path) else {
6797        return false;
6798    };
6799    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
6800        return false;
6801    };
6802    value.get("version").and_then(serde_json::Value::as_u64)
6803        == Some(MIGRATION_MANIFEST_VERSION as u64)
6804        && value
6805            .get("target_generation")
6806            .and_then(serde_json::Value::as_str)
6807            == Some(generation)
6808        && value
6809            .get("source_bytes")
6810            .and_then(serde_json::Value::as_u64)
6811            .is_some_and(|bytes| bytes > 0)
6812        && value
6813            .get("source_blake3")
6814            .and_then(serde_json::Value::as_str)
6815            .is_some_and(|hash| hash.len() == 64)
6816}
6817
6818fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
6819    let pointer_generation = read_pointer(callgraph_dir, project_key);
6820    if let Some(generation) = pointer_generation.as_deref() {
6821        if migration_generation_requires_manifest(generation)
6822            && !migration_manifest_valid(callgraph_dir, generation)
6823        {
6824            let path = callgraph_dir.join(generation);
6825            remove_sqlite_file_set(&path);
6826            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
6827            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
6828        }
6829    }
6830
6831    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
6832        return;
6833    };
6834    for entry in entries.flatten() {
6835        let name = entry.file_name().to_string_lossy().to_string();
6836        let path = entry.path();
6837        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
6838            let _ = std::fs::remove_file(path);
6839            continue;
6840        }
6841        if name.starts_with(&format!("{project_key}.g"))
6842            && name.ends_with(".sqlite")
6843            && name.contains(MIGRATION_GENERATION_TAG)
6844            && pointer_generation.as_deref() != Some(&name)
6845            && !migration_manifest_valid(callgraph_dir, &name)
6846        {
6847            remove_sqlite_file_set(&path);
6848            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
6849        }
6850    }
6851    crate::fs_lock::sync_parent(callgraph_dir);
6852}
6853
6854fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
6855    let mut hasher = blake3::Hasher::new();
6856    hasher.update(path.to_string_lossy().as_bytes());
6857    if let Some(generation) = generation {
6858        hasher.update(generation.as_bytes());
6859    }
6860    let digest = hash_to_hex(hasher.finalize());
6861    format!("legacy-{}", &digest[..16])
6862}
6863
6864fn open_readonly_connection(path: &Path) -> Result<Connection> {
6865    let uri = sqlite_readonly_uri(path);
6866    let conn = Connection::open_with_flags(
6867        &uri,
6868        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
6869    )?;
6870    conn.pragma_update(
6871        None,
6872        "synchronous",
6873        if write_amplification_baseline_enabled() {
6874            "FULL"
6875        } else {
6876            "NORMAL"
6877        },
6878    )?;
6879    conn.busy_timeout(reader_busy_timeout())?;
6880    conn.execute_batch("PRAGMA query_only=ON;")?;
6881    Ok(conn)
6882}
6883
6884fn reader_busy_timeout() -> Duration {
6885    let jitter = (now_nanos() % 500) as u64;
6886    Duration::from_millis(250 + jitter)
6887}
6888
6889fn sqlite_readonly_uri(path: &Path) -> String {
6890    let raw = path.to_string_lossy().replace('\\', "/");
6891    let encoded = percent_encode_sqlite_uri_path(&raw);
6892    if raw.starts_with('/') {
6893        format!("file://{encoded}?mode=ro")
6894    } else if raw.as_bytes().get(1) == Some(&b':') {
6895        format!("file:///{encoded}?mode=ro")
6896    } else {
6897        format!("file:{encoded}?mode=ro")
6898    }
6899}
6900
6901fn percent_encode_sqlite_uri_path(path: &str) -> String {
6902    let mut encoded = String::with_capacity(path.len());
6903    for byte in path.bytes() {
6904        match byte {
6905            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
6906                encoded.push(byte as char)
6907            }
6908            _ => encoded.push_str(&format!("%{byte:02X}")),
6909        }
6910    }
6911    encoded
6912}
6913
6914fn configure_connection(conn: &Connection) -> Result<()> {
6915    // Changing journal mode takes a database lock. Install the busy handler
6916    // first so concurrent cold-build and refresh connections wait rather than
6917    // failing immediately, especially under Windows byte-range locking.
6918    conn.busy_timeout(Duration::from_secs(5))?;
6919    conn.pragma_update(None, "journal_mode", "WAL")?;
6920    let baseline = write_amplification_baseline_enabled();
6921    conn.pragma_update(
6922        None,
6923        "synchronous",
6924        if baseline { "FULL" } else { "NORMAL" },
6925    )?;
6926    conn.pragma_update(
6927        None,
6928        "wal_autocheckpoint",
6929        if baseline {
6930            1_000
6931        } else {
6932            CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES
6933        },
6934    )?;
6935    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6936    Ok(())
6937}
6938
6939fn configure_build_connection(conn: &Connection) -> Result<()> {
6940    // The staging database commits independently recoverable batches. WAL keeps
6941    // those commits durable without forcing a rollback journal rewrite per batch.
6942    // Set the busy handler before WAL because selecting the journal mode itself
6943    // can contend with a connection finishing an earlier staged transaction.
6944    conn.busy_timeout(Duration::from_secs(5))?;
6945    conn.pragma_update(None, "journal_mode", "WAL")?;
6946    conn.pragma_update(
6947        None,
6948        "synchronous",
6949        if write_amplification_baseline_enabled() {
6950            "FULL"
6951        } else {
6952            "NORMAL"
6953        },
6954    )?;
6955    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
6956    Ok(())
6957}
6958
6959/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
6960/// private temporary copy before publishing it; a busy reader is harmless because
6961/// the next publication or cleanup pass can retry without affecting the source.
6962fn checkpoint_sqlite_before_publication(path: &Path) {
6963    let Ok(conn) = Connection::open(path) else {
6964        return;
6965    };
6966    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
6967    let _ = conn.busy_timeout(Duration::from_secs(5));
6968    let _ = checkpoint_wal_truncate(&conn);
6969}
6970
6971fn checkpoint_wal_truncate(conn: &Connection) -> bool {
6972    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
6973        row.get::<_, i64>(0)
6974    }) {
6975        Ok(0) => true,
6976        Ok(_) => false,
6977        Err(rusqlite::Error::SqliteFailure(error, _))
6978            if matches!(
6979                error.code,
6980                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
6981            ) =>
6982        {
6983            false
6984        }
6985        Err(error) => {
6986            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
6987            false
6988        }
6989    }
6990}
6991
6992fn initialize_schema(conn: &Connection) -> Result<()> {
6993    conn.execute_batch(
6994        "CREATE TABLE IF NOT EXISTS files (
6995            path                TEXT PRIMARY KEY,
6996            content_hash        TEXT NOT NULL,
6997            mtime_ns            INTEGER NOT NULL,
6998            size                INTEGER NOT NULL,
6999            lang                TEXT NOT NULL,
7000            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
7001            is_public_api       INTEGER NOT NULL DEFAULT 0,
7002            surface_fingerprint TEXT NOT NULL,
7003            indexed_at          INTEGER NOT NULL
7004        );
7005
7006        CREATE TABLE IF NOT EXISTS nodes (
7007            id                         TEXT PRIMARY KEY,
7008            file_path                  TEXT NOT NULL,
7009            name                       TEXT NOT NULL,
7010            scoped_name                TEXT NOT NULL,
7011            kind                       TEXT NOT NULL,
7012            start_line                 INTEGER NOT NULL,
7013            start_col                  INTEGER NOT NULL,
7014            end_line                   INTEGER NOT NULL,
7015            end_col                    INTEGER NOT NULL,
7016            range_ordinal              INTEGER NOT NULL,
7017            signature                  TEXT,
7018            exported                   INTEGER NOT NULL,
7019            is_default_export          INTEGER NOT NULL,
7020            is_type_like               INTEGER NOT NULL,
7021            is_callgraph_entry_point   INTEGER NOT NULL,
7022            provenance                 TEXT NOT NULL,
7023            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
7024        );
7025        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7026        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7027        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7028
7029        CREATE TABLE IF NOT EXISTS refs (
7030            ref_id          TEXT PRIMARY KEY,
7031            caller_node     TEXT,
7032            caller_file     TEXT NOT NULL,
7033            kind            TEXT NOT NULL,
7034            short_name      TEXT,
7035            full_ref        TEXT,
7036            module_path     TEXT,
7037            import_kind     TEXT,
7038            local_name      TEXT,
7039            requested_name  TEXT,
7040            namespace_alias TEXT,
7041            wildcard        INTEGER NOT NULL DEFAULT 0,
7042            line            INTEGER NOT NULL,
7043            byte_start      INTEGER NOT NULL,
7044            byte_end        INTEGER NOT NULL,
7045            status          TEXT NOT NULL,
7046            target_node     TEXT,
7047            target_file     TEXT,
7048            target_symbol   TEXT,
7049            provenance      TEXT NOT NULL
7050        );
7051        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7052        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7053        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7054        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7055        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7056
7057        CREATE TABLE IF NOT EXISTS file_dependencies (
7058            file_path   TEXT NOT NULL,
7059            dep_file    TEXT NOT NULL,
7060            PRIMARY KEY(file_path, dep_file)
7061        );
7062        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7063
7064        CREATE TABLE IF NOT EXISTS edges (
7065            edge_id       TEXT PRIMARY KEY,
7066            ref_id        TEXT NOT NULL,
7067            source_node   TEXT NOT NULL,
7068            target_node   TEXT,
7069            target_file   TEXT NOT NULL,
7070            target_symbol TEXT NOT NULL,
7071            kind          TEXT NOT NULL,
7072            line          INTEGER NOT NULL,
7073            provenance    TEXT NOT NULL
7074        );
7075        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7076        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7077        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7078        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7079
7080        CREATE TABLE IF NOT EXISTS dispatch_hints (
7081            id           TEXT PRIMARY KEY,
7082            method_name  TEXT NOT NULL,
7083            caller_node  TEXT NOT NULL,
7084            file         TEXT NOT NULL,
7085            line         INTEGER NOT NULL,
7086            byte_start   INTEGER NOT NULL,
7087            byte_end     INTEGER NOT NULL,
7088            provenance   TEXT NOT NULL
7089        );
7090        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7091        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7092
7093        CREATE TABLE IF NOT EXISTS type_ref_names (
7094            name TEXT PRIMARY KEY
7095        );
7096
7097        CREATE TABLE IF NOT EXISTS backend_file_state (
7098            backend        TEXT NOT NULL,
7099            workspace_root TEXT NOT NULL,
7100            file_path      TEXT NOT NULL,
7101            content_hash   TEXT NOT NULL,
7102            status         TEXT NOT NULL,
7103            updated_at     INTEGER NOT NULL,
7104            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7105        );
7106        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7107
7108        CREATE TABLE IF NOT EXISTS meta (
7109            k TEXT PRIMARY KEY,
7110            v TEXT NOT NULL
7111        );
7112
7113        -- The file walk is staged on disk so extraction can page through a
7114        -- deterministic inventory without retaining every source path in heap.
7115        CREATE TABLE IF NOT EXISTS staging_file_inventory (
7116            path TEXT PRIMARY KEY,
7117            size INTEGER NOT NULL
7118        ) WITHOUT ROWID;
7119
7120        -- Context needed only while a generation is staged. Raw refs live in
7121        -- `refs` with status `staged`; this table preserves the caller symbol
7122        -- needed to avoid inventing self edges during the later resolve pass.
7123        CREATE TABLE IF NOT EXISTS staging_ref_context (
7124            ref_id        TEXT PRIMARY KEY,
7125            caller_symbol TEXT
7126        );",
7127    )?;
7128    insert_meta(conn)?;
7129    Ok(())
7130}
7131
7132fn insert_meta(conn: &Connection) -> Result<()> {
7133    conn.execute(
7134        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7135        params![SCHEMA_VERSION.to_string()],
7136    )?;
7137    conn.execute(
7138        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7139        params![schema_fingerprint()],
7140    )?;
7141    conn.execute(
7142        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7143        [],
7144    )?;
7145    Ok(())
7146}
7147
7148/// Return the durable revision paired atomically with graph mutations. Stores
7149/// created by older binaries lack the revision row, so callers cannot detect
7150/// in-place graph changes and must not cache their snapshots.
7151const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7152
7153fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7154    let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7155        return Ok(());
7156    };
7157    conn.execute(
7158        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7159        params![
7160            PATH_IDENTITY_MISMATCH_META_KEY,
7161            format!(
7162                "callgraph_path_identity_mismatch path={} project_root={}",
7163                path.display(),
7164                project_root.display()
7165            )
7166        ],
7167    )?;
7168    Ok(())
7169}
7170
7171pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7172    conn.query_row(
7173        "SELECT v FROM meta WHERE k = ?1",
7174        [PATH_IDENTITY_MISMATCH_META_KEY],
7175        |row| row.get(0),
7176    )
7177    .optional()
7178    .map_err(Into::into)
7179}
7180
7181fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7182    let revision: Option<String> = conn
7183        .query_row(
7184            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7185            [],
7186            |row| row.get(0),
7187        )
7188        .optional()?;
7189    revision
7190        .map(|revision| {
7191            revision.parse::<u64>().map_err(|error| {
7192                CallGraphStoreError::Unavailable(format!(
7193                    "callgraph projection write revision is invalid: {error}"
7194                ))
7195            })
7196        })
7197        .transpose()
7198}
7199
7200/// Advance the projection revision inside the graph mutation transaction so a
7201/// cached snapshot never survives an in-place refresh.
7202fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7203    tx.execute(
7204        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7205         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7206        [],
7207    )?;
7208    Ok(())
7209}
7210
7211fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7212    conn.execute(
7213        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7214        params![if ready { "1" } else { "0" }],
7215    )?;
7216    Ok(())
7217}
7218
7219fn database_ready(conn: &Connection) -> Result<bool> {
7220    let schema_version: Option<String> = conn
7221        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7222            row.get(0)
7223        })
7224        .optional()?;
7225    let fingerprint: Option<String> = conn
7226        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7227            row.get(0)
7228        })
7229        .optional()?;
7230    let ready: Option<String> = conn
7231        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7232        .optional()?;
7233
7234    let expected_schema = SCHEMA_VERSION.to_string();
7235    let expected_fingerprint = schema_fingerprint();
7236    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7237        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7238        && ready.as_deref() == Some("1"))
7239}
7240
7241fn ensure_database_ready(conn: &Connection) -> Result<()> {
7242    if database_ready(conn)? {
7243        Ok(())
7244    } else {
7245        Err(CallGraphStoreError::Unavailable(
7246            "database is missing, stale, or mid-build".to_string(),
7247        ))
7248    }
7249}
7250
7251fn schema_fingerprint() -> String {
7252    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
7253    // edge sources, broader call extraction) even if the table SHAPE is
7254    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
7255    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
7256    let input =
7257        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7258    hash_to_hex(blake3::hash(input.as_bytes()))
7259}
7260
7261fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7262    tx.execute_batch(
7263        "DELETE FROM staging_ref_context;
7264         DELETE FROM edges;
7265         DELETE FROM file_dependencies;
7266         DELETE FROM refs;
7267         DELETE FROM dispatch_hints;
7268         DELETE FROM type_ref_names;
7269         DELETE FROM backend_file_state;
7270         DELETE FROM nodes;
7271         DELETE FROM files;",
7272    )?;
7273    Ok(())
7274}
7275
7276fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7277    conn.query_row(
7278        "SELECT v FROM meta WHERE k = ?1",
7279        params![STAGED_BUILD_PHASE],
7280        |row| row.get(0),
7281    )
7282    .optional()
7283    .map_err(Into::into)
7284}
7285
7286fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7287    let value = staged_string(conn, key)?;
7288    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7289}
7290
7291fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7292    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7293        row.get::<_, String>(0)
7294    })
7295    .optional()
7296    .map_err(Into::into)
7297}
7298
7299fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7300    tx.execute(
7301        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7302        params![STAGED_BUILD_PHASE, phase],
7303    )?;
7304    Ok(())
7305}
7306
7307fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7308    set_staged_string(tx, key, &value.to_string())
7309}
7310
7311fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7312    tx.execute(
7313        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7314        params![key, value],
7315    )?;
7316    Ok(())
7317}
7318
7319/// The extract rows and this counter update share a SQLite transaction. This is
7320/// intentionally not inferred from file/page growth: rollback removes both the
7321/// rows and the claimed credit, while page reuse cannot fabricate credit.
7322fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7323    tx.execute(
7324        "INSERT INTO meta(k, v) VALUES(?1, ?2)
7325         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7326        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7327    )?;
7328    Ok(())
7329}
7330
7331fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7332    let Ok(source) = std::fs::read_to_string(path) else {
7333        return Ok(false);
7334    };
7335    let Ok(freshness) = collect_source_freshness(path, &source) else {
7336        return Ok(false);
7337    };
7338    let rel_path = relative_path(project_root, path);
7339    let staged_hash = conn
7340        .query_row(
7341            "SELECT content_hash FROM files WHERE path = ?1",
7342            params![rel_path],
7343            |row| row.get::<_, String>(0),
7344        )
7345        .optional()?;
7346    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7347}
7348
7349fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7350    tx.execute(
7351        "DELETE FROM staging_ref_context
7352         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7353        params![rel_path],
7354    )?;
7355    delete_file_rows(tx, rel_path)
7356}
7357
7358fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7359    loop {
7360        let removed = {
7361            let mut statement = conn.prepare(
7362                "SELECT path
7363                 FROM files
7364                 WHERE NOT EXISTS (
7365                     SELECT 1 FROM staging_file_inventory inventory
7366                     WHERE inventory.path = files.path
7367                 )
7368                 ORDER BY path
7369                 LIMIT ?1",
7370            )?;
7371            let paths = statement
7372                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7373                    row.get::<_, String>(0)
7374                })?
7375                .collect::<std::result::Result<Vec<_>, _>>()?;
7376            paths
7377        };
7378        if removed.is_empty() {
7379            return Ok(());
7380        }
7381        let tx = conn.transaction()?;
7382        for path in removed {
7383            delete_staged_file_rows(&tx, &path)?;
7384        }
7385        tx.commit()?;
7386    }
7387}
7388
7389struct StagedFileBatch {
7390    paths: Vec<PathBuf>,
7391    last_path: String,
7392}
7393
7394fn load_staged_file_batch(
7395    conn: &Connection,
7396    project_root: &Path,
7397    after_path: &str,
7398    max_files: usize,
7399    max_bytes: u64,
7400) -> Result<Option<StagedFileBatch>> {
7401    let mut statement = conn.prepare(
7402        "SELECT path, size
7403         FROM staging_file_inventory
7404         WHERE path > ?1
7405         ORDER BY path
7406         LIMIT ?2",
7407    )?;
7408    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7409    let mut paths = Vec::with_capacity(max_files.max(1));
7410    let mut last_path = String::new();
7411    let mut batch_bytes = 0u64;
7412    while let Some(row) = rows.next()? {
7413        let rel_path = row.get::<_, String>(0)?;
7414        let size = row.get::<_, i64>(1)?.max(0) as u64;
7415        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7416            break;
7417        }
7418        batch_bytes = batch_bytes.saturating_add(size);
7419        last_path.clone_from(&rel_path);
7420        paths.push(project_root.join(rel_path));
7421    }
7422    if paths.is_empty() {
7423        Ok(None)
7424    } else {
7425        Ok(Some(StagedFileBatch { paths, last_path }))
7426    }
7427}
7428
7429fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7430    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7431    let mut rows = statement.query([])?;
7432    let mut fingerprint = CorpusFingerprint::default();
7433    while let Some(row) = rows.next()? {
7434        let rel_path = row.get::<_, String>(0)?;
7435        fingerprint.add_path(project_root, &project_root.join(rel_path));
7436    }
7437    Ok(fingerprint.finish(project_root))
7438}
7439
7440fn load_staged_ref_window(
7441    conn: &Connection,
7442    after_rowid: u64,
7443    limit: usize,
7444) -> Result<Vec<StagedRef>> {
7445    let mut statement = conn.prepare(
7446        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7447                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7448                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7449                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7450         FROM refs
7451         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7452         WHERE refs.status = 'staged' AND refs.rowid > ?1
7453         ORDER BY refs.rowid
7454         LIMIT ?2",
7455    )?;
7456    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7457        Ok(StagedRef {
7458            rowid: row.get::<_, i64>(0)? as u64,
7459            raw: RawRef {
7460                ref_id: row.get(1)?,
7461                caller_node: row.get(2)?,
7462                caller_file: row.get(3)?,
7463                kind: row.get(4)?,
7464                short_name: row.get(5)?,
7465                full_ref: row.get(6)?,
7466                module_path: row.get(7)?,
7467                import_kind: row.get(8)?,
7468                local_name: row.get(9)?,
7469                requested_name: row.get(10)?,
7470                namespace_alias: row.get(11)?,
7471                wildcard: row.get::<_, i64>(12)? != 0,
7472                line: row.get::<_, i64>(13)? as u32,
7473                byte_start: row.get::<_, i64>(14)? as usize,
7474                byte_end: row.get::<_, i64>(15)? as usize,
7475                caller_symbol: row.get(16)?,
7476                dependencies: BTreeSet::new(),
7477            },
7478        })
7479    })?;
7480    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7481    drop(statement);
7482
7483    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7484    let mut dependency_statement = conn
7485        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7486    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7487        if !dependencies.contains_key(&raw.caller_file) {
7488            let rows =
7489                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7490            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7491            dependencies.insert(raw.caller_file.clone(), values);
7492        }
7493        raw.dependencies = dependencies
7494            .get(&raw.caller_file)
7495            .cloned()
7496            .unwrap_or_default();
7497    }
7498    Ok(refs)
7499}
7500
7501fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7502    ResolvedRef {
7503        dependencies: raw.dependencies.clone(),
7504        raw,
7505        status: "unresolved".to_string(),
7506        target_node: None,
7507        target_file: None,
7508        target_symbol: None,
7509        edge: None,
7510    }
7511}
7512
7513fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7514    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7515        .map(|count| count.max(0) as u64)
7516        .map_err(Into::into)
7517}
7518
7519fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7520    let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7521    let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7522    let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7523    let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7524    let failed_files = staged_failed_files(conn)?;
7525    let elapsed_ms = started.elapsed().as_millis();
7526    crate::slog_info!(
7527        "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7528        files,
7529        nodes,
7530        refs,
7531        edges,
7532        staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7533        elapsed_ms
7534    );
7535    Ok(ColdBuildStats {
7536        files,
7537        nodes,
7538        refs,
7539        edges,
7540        failed_files,
7541        elapsed_ms,
7542    })
7543}
7544
7545fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7546    let mut statement = conn.prepare(
7547        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7548    )?;
7549    let rows = statement.query_map([], |row| row.get(0))?;
7550    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7551}
7552
7553fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7554    tx.execute_batch(
7555        "DROP INDEX IF EXISTS idx_nodes_file;
7556         DROP INDEX IF EXISTS idx_nodes_name;
7557         DROP INDEX IF EXISTS idx_nodes_scoped;
7558         DROP INDEX IF EXISTS idx_refs_short_name;
7559         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7560         DROP INDEX IF EXISTS idx_refs_caller_file;
7561         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7562         DROP INDEX IF EXISTS idx_refs_target_file;
7563         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7564         DROP INDEX IF EXISTS idx_edges_source_kind;
7565         DROP INDEX IF EXISTS idx_edges_target_kind;
7566         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7567         DROP INDEX IF EXISTS idx_edges_ref_id;
7568         DROP INDEX IF EXISTS idx_dispatch_hints_method;
7569         DROP INDEX IF EXISTS idx_dispatch_hints_file;
7570         DROP INDEX IF EXISTS idx_backend_file_state_file;",
7571    )?;
7572    Ok(())
7573}
7574
7575fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7576    tx.execute_batch(
7577        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7578         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7579         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7580         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7581         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7582         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7583         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7584         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7585         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7586         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7587         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7588         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7589         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7590         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7591         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7592         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7593    )?;
7594    Ok(())
7595}
7596
7597const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7598    ("files", "path"),
7599    ("nodes", "file_path"),
7600    ("refs", "caller_file"),
7601    ("refs", "target_file"),
7602    ("file_dependencies", "file_path"),
7603    ("file_dependencies", "dep_file"),
7604    ("edges", "target_file"),
7605    ("dispatch_hints", "file"),
7606    ("backend_file_state", "file_path"),
7607];
7608
7609/// Reconcile `backend_file_state.workspace_root` when the opener's project root
7610/// differs from what is stored. The store key is the git-root commit hash, so
7611/// multiple live checkouts/clones share one on-disk generation.
7612///
7613/// Cheap in-place re-root is only safe when every previously stored root path is
7614/// gone from disk (true move/rename). If any stale root still exists, another
7615/// clone is still alive and rewriting metadata would ping-pong relative rows
7616/// between trees (possibly on different branches). We then return
7617/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
7618/// opener. That can make each clone rebuild on open when they alternate — bounded
7619/// by open frequency — but each rebuild is correct for its opener, unlike silent
7620/// cross-clone corruption.
7621fn reconcile_workspace_roots(
7622    conn: &mut Connection,
7623    project_root: &Path,
7624    allow_repair: bool,
7625) -> Result<OpenRootRepair> {
7626    let roots = stored_workspace_roots(conn)?;
7627    let current_root = project_root.display().to_string();
7628    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
7629        return Ok(OpenRootRepair::None);
7630    }
7631
7632    if let Some(sample) = sample_absolute_data_path(conn)? {
7633        return Ok(OpenRootRepair::NeedsRebuild {
7634            previous_roots: roots,
7635            current_root,
7636            reason: format!("absolute store data path row {sample}"),
7637        });
7638    }
7639
7640    for stored_root in roots.iter() {
7641        if stored_root == &current_root {
7642            continue;
7643        }
7644        if Path::new(stored_root).exists() {
7645            let reason = format!(
7646                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
7647            );
7648            return Ok(OpenRootRepair::NeedsRebuild {
7649                previous_roots: roots,
7650                current_root,
7651                reason,
7652            });
7653        }
7654    }
7655
7656    if !allow_repair {
7657        return Ok(OpenRootRepair::NeedsRebuild {
7658            previous_roots: roots,
7659            current_root,
7660            reason: "workspace root metadata requires deferred repair".to_string(),
7661        });
7662    }
7663
7664    publish_if_current(|| {
7665        let tx = conn.transaction()?;
7666        tx.execute(
7667            "UPDATE OR IGNORE backend_file_state
7668             SET workspace_root = ?1
7669             WHERE workspace_root <> ?1",
7670            params![&current_root],
7671        )?;
7672        tx.execute(
7673            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
7674            params![&current_root],
7675        )?;
7676        tx.commit()?;
7677        Ok(())
7678    })?;
7679
7680    crate::slog_info!(
7681        "callgraph store re-rooted from {} to {}",
7682        roots.join(", "),
7683        current_root
7684    );
7685    Ok(OpenRootRepair::ReRooted)
7686}
7687
7688fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
7689    let mut stmt = conn.prepare(
7690        "SELECT DISTINCT workspace_root
7691         FROM backend_file_state
7692         ORDER BY workspace_root",
7693    )?;
7694    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
7695    rows.collect::<std::result::Result<Vec<_>, _>>()
7696        .map_err(Into::into)
7697}
7698
7699fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
7700    for (table, column) in STORE_DATA_PATH_COLUMNS {
7701        let sql = format!(
7702            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
7703        );
7704        let mut stmt = conn.prepare(&sql)?;
7705        let mut rows = stmt.query([])?;
7706        while let Some(row) = rows.next()? {
7707            let value: String = row.get(0)?;
7708            if stored_path_is_absolute(&value) {
7709                return Ok(Some(format!("{table}.{column}={value}")));
7710            }
7711        }
7712    }
7713    Ok(None)
7714}
7715
7716fn stored_path_is_absolute(value: &str) -> bool {
7717    if value.is_empty() {
7718        return false;
7719    }
7720    if Path::new(value).is_absolute() || value.starts_with('/') {
7721        return true;
7722    }
7723    let bytes = value.as_bytes();
7724    if bytes.len() >= 3
7725        && bytes[1] == b':'
7726        && (bytes[2] == b'/' || bytes[2] == b'\\')
7727        && bytes[0].is_ascii_alphabetic()
7728    {
7729        return true;
7730    }
7731    value.starts_with("\\\\") || value.starts_with("//")
7732}
7733
7734fn log_root_repair_rebuild(repair: &OpenRootRepair) {
7735    if let OpenRootRepair::NeedsRebuild {
7736        previous_roots,
7737        current_root,
7738        reason,
7739    } = repair
7740    {
7741        crate::slog_info!(
7742            "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
7743            previous_roots.join(", "),
7744            current_root,
7745            reason
7746        );
7747    }
7748}
7749
7750/// Nanosecond clock used to make temp/generation file names unique.
7751fn now_nanos() -> u128 {
7752    SystemTime::now()
7753        .duration_since(UNIX_EPOCH)
7754        .unwrap_or(Duration::ZERO)
7755        .as_nanos()
7756}
7757
7758/// The pointer file `<dir>/<key>.current`. Its single line names the current
7759/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
7760/// can always be atomically replaced via rename even on Windows — Rust opens
7761/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
7762fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7763    callgraph_dir.join(format!("{project_key}.current"))
7764}
7765
7766/// The legacy single-file DB path used before the generation scheme. Still read
7767/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
7768/// build publishes a generation.
7769fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
7770    callgraph_dir.join(format!("{project_key}.sqlite"))
7771}
7772
7773/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
7774/// cold build writes a brand-new generation file, so publishing NEVER replaces
7775/// a file another process holds open (the root Windows fix).
7776fn generation_file_name(project_key: &str) -> String {
7777    format!(
7778        "{project_key}.g{}.{}.sqlite",
7779        now_nanos(),
7780        std::process::id()
7781    )
7782}
7783
7784/// Read the pointer; returns the generation file name if present and non-empty.
7785fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
7786    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
7787    let name = text.trim();
7788    if name.is_empty() {
7789        None
7790    } else {
7791        Some(name.to_string())
7792    }
7793}
7794
7795/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
7796/// `ready` flag). Uses a throwaway read-only connection.
7797fn db_path_ready(path: &Path) -> bool {
7798    (|| -> Result<bool> {
7799        let conn = open_readonly_connection(path)?;
7800        database_ready(&conn)
7801    })()
7802    .unwrap_or(false)
7803}
7804
7805/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
7806/// where `generation` is `Some(name)` for a pointer-published generation or
7807/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
7808/// published (caller treats that as "needs cold build").
7809///
7810/// Handles the GC race (the pointer names a generation that was just deleted) by
7811/// re-reading the pointer and retrying a few times.
7812fn resolve_ready_target(
7813    callgraph_dir: &Path,
7814    project_key: &str,
7815) -> Option<(PathBuf, Option<String>)> {
7816    for _ in 0..5 {
7817        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
7818            let gen_path = callgraph_dir.join(&generation);
7819            if gen_path.is_file() {
7820                return (migration_manifest_valid(callgraph_dir, &generation)
7821                    && db_path_ready(&gen_path))
7822                .then_some((gen_path, Some(generation)));
7823            }
7824            // Pointer names a missing generation (a GC/publish race): re-read the
7825            // pointer and retry rather than failing the reader.
7826            std::thread::sleep(Duration::from_millis(5));
7827            continue;
7828        }
7829        // No pointer: fall back to the legacy single-file DB if it is ready.
7830        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
7831        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
7832    }
7833    None
7834}
7835
7836/// Atomically publish `generation` as the current store by flipping the pointer
7837/// file. Writes a temp file, fsyncs, then renames over the pointer — never
7838/// replacing an open DB file, so it succeeds cross-platform.
7839fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
7840    let pointer = pointer_path(callgraph_dir, project_key);
7841    let tmp = callgraph_dir.join(format!(
7842        "{project_key}.current.tmp.{}.{}",
7843        std::process::id(),
7844        now_nanos()
7845    ));
7846    {
7847        use std::io::Write as _;
7848        let mut file = std::fs::File::create(&tmp)?;
7849        file.write_all(generation.as_bytes())?;
7850        file.write_all(b"\n")?;
7851        file.sync_all()?;
7852    }
7853    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
7854        let _ = std::fs::remove_file(&tmp);
7855        return Err(error.into());
7856    }
7857    crate::fs_lock::sync_parent(&pointer);
7858    Ok(())
7859}
7860
7861#[derive(Clone, Debug)]
7862struct GenerationGcCandidate {
7863    name: String,
7864    path: PathBuf,
7865    modified: SystemTime,
7866}
7867
7868/// Best-effort GC of superseded generation files. The current pointer target and
7869/// newest previous generation are always retained. Older generations are removed
7870/// when they have no protected read marker, or after the absolute retention TTL
7871/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
7872/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
7873fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
7874    let temp_grace = Duration::from_secs(60);
7875    let now = SystemTime::now();
7876    let pointer_current =
7877        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
7878    let gen_prefix = format!("{project_key}.g");
7879    let tmp_prefixes = [
7880        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
7881        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
7882        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
7883    ];
7884    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7885        return;
7886    };
7887    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
7888    for entry in entries.flatten() {
7889        let name = entry.file_name();
7890        let name = name.to_string_lossy().to_string();
7891        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
7892        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
7893
7894        // Orphaned temp files from a crashed build/publish: remove once aged out.
7895        if name.contains(".tmp.") {
7896            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
7897                let _ = std::fs::remove_file(entry.path());
7898            }
7899            continue;
7900        }
7901
7902        // Superseded legacy single-file DB: best-effort delete once a generation
7903        // is published (ignored if another process still holds it open).
7904        if name == format!("{project_key}.sqlite") {
7905            remove_sqlite_file_set(&entry.path());
7906            continue;
7907        }
7908
7909        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
7910            gens.push(GenerationGcCandidate {
7911                name,
7912                path: entry.path(),
7913                modified: mtime,
7914            });
7915        }
7916    }
7917
7918    let mut superseded = gens
7919        .iter()
7920        .filter(|generation| generation.name != pointer_current)
7921        .collect::<Vec<_>>();
7922    superseded.sort_by(|left, right| {
7923        right
7924            .modified
7925            .cmp(&left.modified)
7926            .then_with(|| right.name.cmp(&left.name))
7927    });
7928    let previous = superseded.first().map(|generation| generation.name.clone());
7929
7930    for generation in gens {
7931        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
7932        if generation.name == pointer_current
7933            || Some(generation.name.as_str()) == previous.as_deref()
7934        {
7935            continue;
7936        }
7937
7938        let age = now
7939            .duration_since(generation.modified)
7940            .unwrap_or(Duration::ZERO);
7941        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
7942            continue;
7943        }
7944
7945        remove_sqlite_file_set(&generation.path);
7946        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
7947        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
7948            callgraph_dir,
7949            &generation.name,
7950        ));
7951    }
7952}
7953
7954fn remove_sqlite_file_set(path: &Path) {
7955    let _ = std::fs::remove_file(path);
7956    remove_sqlite_sidecars(path);
7957}
7958
7959fn remove_sqlite_sidecars(path: &Path) {
7960    let path_text = path.to_string_lossy();
7961    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
7962    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
7963    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
7964}
7965
7966#[derive(Clone, Copy, Debug, Default)]
7967struct CallgraphRootSweepSummary {
7968    scanned: usize,
7969    removed: usize,
7970    bytes: u64,
7971    generation_gc: usize,
7972    skipped_memo: usize,
7973    skipped_derived: usize,
7974    skipped_fresh: usize,
7975    skipped_reader: usize,
7976    skipped_lease: usize,
7977    skipped_unreadable: usize,
7978    budget_exhausted: bool,
7979}
7980
7981#[derive(Clone, Copy, Debug, Default)]
7982struct CallgraphRootFileStats {
7983    newest: Option<SystemTime>,
7984    bytes: u64,
7985}
7986
7987enum CallgraphRootWalk {
7988    Complete(CallgraphRootFileStats),
7989    BudgetExceeded,
7990    Failed,
7991}
7992
7993enum CallgraphRootCandidate {
7994    Removed { bytes: u64 },
7995    GenerationGc,
7996    SkippedMemo,
7997    SkippedDerived,
7998    SkippedFresh,
7999    SkippedReader,
8000    SkippedLease,
8001    SkippedUnreadable,
8002    BudgetExceeded,
8003}
8004
8005/// Sweep root-keyed callgraph directories that were detached when cache-key
8006/// eviction forgot a checkout. Generation GC alone only runs while a root
8007/// publishes, so inactive roots otherwise keep every obsolete generation forever.
8008///
8009/// The pass reuses the index-cache liveness boundary and takes each directory's
8010/// writer lease before mutating it. A current memo entry remains eligible only for
8011/// superseded-generation GC; an absent entry is eligible for whole-directory
8012/// deletion after the conservative age threshold.
8013fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
8014    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8015        return;
8016    };
8017    let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
8018    let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
8019        Ok(keys) => keys,
8020        Err(error) => {
8021            crate::slog_warn!(
8022                "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={}",
8023                root_dir.display(),
8024                error
8025            );
8026            return;
8027        }
8028    };
8029    let derived_keys = crate::search_index::derived_artifact_cache_keys();
8030    let summary = sweep_callgraph_root_dirs_with_limits(
8031        &root_dir,
8032        &memo_keys,
8033        &derived_keys,
8034        CALLGRAPH_ROOT_SWEEP_BUDGET,
8035        CALLGRAPH_ROOT_SWEEP_LIMIT,
8036    );
8037    crate::slog_info!(
8038        "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
8039        root_dir.display(),
8040        summary.scanned,
8041        summary.removed,
8042        summary.bytes,
8043        summary.generation_gc,
8044        summary.skipped_memo,
8045        summary.skipped_derived,
8046        summary.skipped_fresh,
8047        summary.skipped_reader,
8048        summary.skipped_lease,
8049        summary.skipped_unreadable,
8050        summary.budget_exhausted
8051    );
8052}
8053
8054fn sweep_callgraph_root_dirs_with_limits(
8055    root_dir: &Path,
8056    memo_keys: &HashSet<String>,
8057    derived_keys: &HashSet<String>,
8058    wall_clock_budget: Duration,
8059    entry_limit: usize,
8060) -> CallgraphRootSweepSummary {
8061    let started = Instant::now();
8062    let deadline = started + wall_clock_budget;
8063    let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
8064        Ok(boundary) => boundary,
8065        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8066            return CallgraphRootSweepSummary::default();
8067        }
8068        Err(error) => {
8069            crate::slog_warn!(
8070                "cannot establish filesystem boundary for callgraph root sweep {}: {}",
8071                root_dir.display(),
8072                error
8073            );
8074            return CallgraphRootSweepSummary {
8075                skipped_unreadable: 1,
8076                ..CallgraphRootSweepSummary::default()
8077            };
8078        }
8079    };
8080    let mut entries = match std::fs::read_dir(root_dir) {
8081        Ok(entries) => entries
8082            .filter_map(|entry| entry.ok())
8083            .filter_map(|entry| {
8084                let name = entry.file_name().to_string_lossy().into_owned();
8085                entry
8086                    .file_type()
8087                    .ok()
8088                    .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8089                    .map(|_| (name, entry.path()))
8090            })
8091            .collect::<Vec<_>>(),
8092        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8093        Err(error) => {
8094            crate::slog_warn!(
8095                "cannot read callgraph root sweep directory {}: {}",
8096                root_dir.display(),
8097                error
8098            );
8099            return CallgraphRootSweepSummary {
8100                skipped_unreadable: 1,
8101                ..CallgraphRootSweepSummary::default()
8102            };
8103        }
8104    };
8105    entries.sort_by(|left, right| left.0.cmp(&right.0));
8106
8107    let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8108    let last_name = cursor_store
8109        .lock()
8110        .ok()
8111        .and_then(|cursors| cursors.get(root_dir).cloned());
8112    if let Some(start) = last_name
8113        .as_deref()
8114        .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8115    {
8116        entries.rotate_left(start);
8117    }
8118
8119    let mut summary = CallgraphRootSweepSummary::default();
8120    let mut cursor_name = last_name;
8121    for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8122        if processed >= entry_limit || Instant::now() >= deadline {
8123            summary.budget_exhausted = true;
8124            break;
8125        }
8126        summary.scanned += 1;
8127        cursor_name = Some(key.clone());
8128        match callgraph_root_candidate(
8129            &cache_dir,
8130            &key,
8131            memo_keys.contains(&key),
8132            derived_keys.contains(&key),
8133            &boundary,
8134            deadline,
8135        ) {
8136            CallgraphRootCandidate::Removed { bytes } => {
8137                summary.removed += 1;
8138                summary.bytes = summary.bytes.saturating_add(bytes);
8139            }
8140            CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8141            CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8142            CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8143            CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8144            CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8145            CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8146            CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8147            CallgraphRootCandidate::BudgetExceeded => {
8148                summary.budget_exhausted = true;
8149                break;
8150            }
8151        }
8152    }
8153
8154    if let Ok(mut cursors) = cursor_store.lock() {
8155        if summary.budget_exhausted {
8156            if let Some(cursor_name) = cursor_name {
8157                cursors.insert(root_dir.to_path_buf(), cursor_name);
8158            }
8159        } else {
8160            cursors.remove(root_dir);
8161        }
8162    }
8163    if summary.removed > 0 {
8164        crate::fs_lock::sync_parent(root_dir);
8165    }
8166    summary
8167}
8168
8169fn callgraph_root_candidate(
8170    cache_dir: &Path,
8171    project_key: &str,
8172    memo_referenced: bool,
8173    derived_in_process: bool,
8174    boundary: &crate::walk_boundary::DeviceBoundary,
8175    deadline: Instant,
8176) -> CallgraphRootCandidate {
8177    if !boundary.should_descend(cache_dir).unwrap_or(false) {
8178        return CallgraphRootCandidate::SkippedUnreadable;
8179    }
8180    if memo_referenced || derived_in_process {
8181        return sweep_live_callgraph_root_generations(
8182            cache_dir,
8183            project_key,
8184            memo_referenced,
8185            boundary,
8186            deadline,
8187        );
8188    }
8189
8190    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8191        CallgraphRootWalk::Complete(stats) => stats,
8192        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8193        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8194    };
8195    let Some(newest) = stats.newest else {
8196        return CallgraphRootCandidate::SkippedUnreadable;
8197    };
8198    if SystemTime::now()
8199        .duration_since(newest)
8200        .unwrap_or(Duration::ZERO)
8201        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8202    {
8203        return CallgraphRootCandidate::SkippedFresh;
8204    }
8205
8206    // Keep the writer lease held through deletion. A concurrent publisher either
8207    // owns it first (and this pass skips) or starts after this directory is gone.
8208    let _writer_lease = match crate::fs_lock::try_acquire(
8209        &crate::root_cache::writer_lease_path(cache_dir),
8210        Duration::ZERO,
8211    ) {
8212        Ok(lease) => lease,
8213        Err(_) => return CallgraphRootCandidate::SkippedLease,
8214    };
8215    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8216        return CallgraphRootCandidate::SkippedReader;
8217    }
8218
8219    match std::fs::remove_dir_all(cache_dir) {
8220        Ok(()) => {
8221            crate::slog_info!(
8222                "callgraph root sweep reaped dir={} key={} bytes={}",
8223                cache_dir.display(),
8224                project_key,
8225                stats.bytes
8226            );
8227            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8228        }
8229        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8230            crate::slog_info!(
8231                "callgraph root sweep reaped dir={} key={} bytes={}",
8232                cache_dir.display(),
8233                project_key,
8234                stats.bytes
8235            );
8236            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8237        }
8238        Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8239    }
8240}
8241
8242fn sweep_live_callgraph_root_generations(
8243    cache_dir: &Path,
8244    project_key: &str,
8245    memo_referenced: bool,
8246    boundary: &crate::walk_boundary::DeviceBoundary,
8247    deadline: Instant,
8248) -> CallgraphRootCandidate {
8249    if Instant::now() >= deadline {
8250        return CallgraphRootCandidate::BudgetExceeded;
8251    }
8252    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8253        CallgraphRootWalk::Complete(stats) => stats,
8254        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8255        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8256    };
8257    let Some(newest) = stats.newest else {
8258        return CallgraphRootCandidate::SkippedUnreadable;
8259    };
8260    if SystemTime::now()
8261        .duration_since(newest)
8262        .unwrap_or(Duration::ZERO)
8263        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8264    {
8265        return CallgraphRootCandidate::SkippedFresh;
8266    }
8267    let _writer_lease = match crate::fs_lock::try_acquire(
8268        &crate::root_cache::writer_lease_path(cache_dir),
8269        Duration::ZERO,
8270    ) {
8271        Ok(lease) => lease,
8272        Err(_) => return CallgraphRootCandidate::SkippedLease,
8273    };
8274    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8275        return CallgraphRootCandidate::SkippedReader;
8276    }
8277    if let Some(current) = read_pointer(cache_dir, project_key) {
8278        gc_old_generations(cache_dir, project_key, &current);
8279        return CallgraphRootCandidate::GenerationGc;
8280    }
8281    if memo_referenced {
8282        CallgraphRootCandidate::SkippedMemo
8283    } else {
8284        CallgraphRootCandidate::SkippedDerived
8285    }
8286}
8287
8288fn callgraph_root_file_stats(
8289    cache_dir: &Path,
8290    boundary: &crate::walk_boundary::DeviceBoundary,
8291    deadline: Instant,
8292) -> CallgraphRootWalk {
8293    let metadata = match std::fs::metadata(cache_dir) {
8294        Ok(metadata) => metadata,
8295        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8296            return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8297        }
8298        Err(_) => return CallgraphRootWalk::Failed,
8299    };
8300    let mut stats = CallgraphRootFileStats {
8301        newest: metadata.modified().ok(),
8302        bytes: 0,
8303    };
8304    match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8305        Ok(()) => CallgraphRootWalk::Complete(stats),
8306        Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8307        Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8308    }
8309}
8310
8311enum CallgraphRootWalkError {
8312    BudgetExceeded,
8313    Failed,
8314}
8315
8316fn callgraph_root_file_stats_inner(
8317    directory: &Path,
8318    boundary: &crate::walk_boundary::DeviceBoundary,
8319    deadline: Instant,
8320    stats: &mut CallgraphRootFileStats,
8321) -> std::result::Result<(), CallgraphRootWalkError> {
8322    if Instant::now() >= deadline {
8323        return Err(CallgraphRootWalkError::BudgetExceeded);
8324    }
8325    let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8326    for entry in entries {
8327        if Instant::now() >= deadline {
8328            return Err(CallgraphRootWalkError::BudgetExceeded);
8329        }
8330        let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8331        let file_type = entry
8332            .file_type()
8333            .map_err(|_| CallgraphRootWalkError::Failed)?;
8334        if file_type.is_symlink() {
8335            return Err(CallgraphRootWalkError::Failed);
8336        }
8337        let path = entry.path();
8338        if file_type.is_dir() {
8339            if !boundary
8340                .should_descend(&path)
8341                .map_err(|_| CallgraphRootWalkError::Failed)?
8342            {
8343                return Err(CallgraphRootWalkError::Failed);
8344            }
8345            let metadata = entry
8346                .metadata()
8347                .map_err(|_| CallgraphRootWalkError::Failed)?;
8348            merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8349            callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8350            continue;
8351        }
8352        if !file_type.is_file() {
8353            return Err(CallgraphRootWalkError::Failed);
8354        }
8355        let metadata = entry
8356            .metadata()
8357            .map_err(|_| CallgraphRootWalkError::Failed)?;
8358        stats.bytes = stats.bytes.saturating_add(metadata.len());
8359        merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8360    }
8361    Ok(())
8362}
8363
8364fn merge_newest_callgraph_root_mtime(
8365    stats: &mut CallgraphRootFileStats,
8366    modified: Option<SystemTime>,
8367) {
8368    if let Some(modified) = modified {
8369        if stats.newest.is_none_or(|newest| modified > newest) {
8370            stats.newest = Some(modified);
8371        }
8372    }
8373}
8374
8375fn artifact_key_looks_valid(key: &str) -> bool {
8376    key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8377}
8378
8379#[cfg(test)]
8380fn reset_callgraph_root_sweep_cursor_for_test() {
8381    if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8382        cursors.lock().unwrap().clear();
8383    }
8384}
8385
8386/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
8387///
8388/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
8389/// place on success; a build that dies (process kill, crash, host restart) leaves
8390/// the temporary behind. The largest observed cold build finishes well under a
8391/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
8392/// never rename. A live build's temporary is minutes old at most.
8393///
8394/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
8395/// liveness check read false-positive on exactly the oldest files — the ones most
8396/// worth deleting: in production an orphan's embedded pid had been recycled to an
8397/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
8398/// cannot lie that way, so it is the honest orphan predicate.
8399const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8400
8401/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
8402/// same cadence as [`gc_old_generations`] (after a generation is published) but,
8403/// unlike it, is not scoped to the building root: it covers every directory in the
8404/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
8405///
8406/// That last case is the production hole this fixes. The per-root cleanup in
8407/// [`gc_old_generations`] only fires when a root actually builds, so when activity
8408/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
8409/// store's orphans become permanent — gigabytes accumulated in a legacy store
8410/// whose roots no longer built there, while the active store stayed clean. A
8411/// sibling root that still builds triggers this pass and cleans both layouts.
8412fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8413    sweep_orphaned_build_temps(callgraph_dir);
8414    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8415        return;
8416    };
8417    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8418    // A vanished mounted child can make ReadDir::drop panic after closedir
8419    // returns ENXIO, aborting the daemon. Keep the store-wide background sweep
8420    // on the storage root's filesystem before opening child directories.
8421    let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8422        crate::slog_warn!(
8423            "cannot establish filesystem boundary for callgraph sweep {}",
8424            storage_root.display()
8425        );
8426        return;
8427    };
8428    let mut skipped_foreign_mounts = 0usize;
8429
8430    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
8431    let root_keyed_dir = storage_root.join(domain);
8432    if root_keyed_dir.is_dir() {
8433        if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8434            if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8435                for entry in entries.flatten() {
8436                    let path = entry.path();
8437                    if path.is_dir() {
8438                        if boundary.should_descend(&path).unwrap_or(false) {
8439                            sweep_orphaned_build_temps(&path);
8440                        } else {
8441                            skipped_foreign_mounts += 1;
8442                        }
8443                    }
8444                }
8445            }
8446        } else {
8447            skipped_foreign_mounts += 1;
8448        }
8449    }
8450
8451    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
8452    if let Ok(entries) = std::fs::read_dir(&storage_root) {
8453        for entry in entries.flatten() {
8454            let harness_dir = entry.path();
8455            if !harness_dir.is_dir() {
8456                continue;
8457            }
8458            if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8459                skipped_foreign_mounts += 1;
8460                continue;
8461            }
8462            let legacy_dir = harness_dir.join(domain);
8463            if legacy_dir.is_dir() {
8464                if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8465                    sweep_orphaned_build_temps(&legacy_dir);
8466                } else {
8467                    skipped_foreign_mounts += 1;
8468                }
8469            }
8470        }
8471    }
8472    if skipped_foreign_mounts > 0 {
8473        crate::slog_warn!(
8474            "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8475            skipped_foreign_mounts,
8476            storage_root.display()
8477        );
8478    }
8479}
8480
8481/// Sweep one callgraph directory, removing build temporaries older than
8482/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
8483fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8484    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8485}
8486
8487/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
8488/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
8489fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8490    let now = SystemTime::now();
8491    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8492        return;
8493    };
8494    let mut removed_any = false;
8495    for entry in entries.flatten() {
8496        let name = entry.file_name().to_string_lossy().to_string();
8497        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
8498        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
8499        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
8500        // without that substring — a completed `.sqlite` generation, a pointer, a
8501        // read-marker dir — is left alone: those belong to generation GC.
8502        if !name.contains(".sqlite.tmp.") {
8503            continue;
8504        }
8505        let mtime = entry
8506            .metadata()
8507            .and_then(|meta| meta.modified())
8508            .unwrap_or(now);
8509        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8510            continue;
8511        }
8512        // Deletion races a concurrent build finishing: that build renames the temp
8513        // into place, so the file is gone by the time we unlink. The 24h age makes
8514        // this overlap practically impossible, but treat a missing file as success
8515        // (the rename won) rather than an error, and never touch a path that does
8516        // not match the temporary shape above.
8517        match std::fs::remove_file(entry.path()) {
8518            Ok(()) => removed_any = true,
8519            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8520            Err(_) => {}
8521        }
8522    }
8523    if removed_any {
8524        crate::fs_lock::sync_parent(callgraph_dir);
8525    }
8526}
8527
8528/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
8529/// the global all-cores rayon pool. The store cold-build is the heaviest
8530/// background pass (parse-dominated) and runs on a separate thread off the
8531/// single-threaded request loop; left unbounded it monopolizes every core and
8532/// starves the bridge so interactive tools time out (the same starvation the
8533/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
8534/// match the main thread, since the extract walks tree-sitter ASTs.
8535fn build_pool_size() -> usize {
8536    std::thread::available_parallelism()
8537        .map(|parallelism| parallelism.get())
8538        .unwrap_or(1)
8539        .div_ceil(2)
8540        .clamp(1, 8)
8541}
8542
8543fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8544    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8545        Ok(extract) => Ok(extract),
8546        Err(error) => {
8547            let abs_path =
8548                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8549            let rel_path = relative_path(project_root, &abs_path);
8550            let freshness = cache_freshness::collect(&abs_path).ok();
8551            log::debug!(
8552                "callgraph store: skipping {} during cold build: {}",
8553                abs_path.display(),
8554                error
8555            );
8556            Err(ExtractFailure {
8557                rel_path,
8558                freshness,
8559            })
8560        }
8561    };
8562
8563    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
8564        files.par_iter().map(extract_one).collect()
8565    };
8566
8567    // Run inside a dedicated bounded pool when one builds; fall back to the
8568    // global pool only if the bounded pool can't be constructed.
8569    let results = match rayon::ThreadPoolBuilder::new()
8570        .num_threads(build_pool_size())
8571        .thread_name(|index| format!("aft-callgraph-build-{index}"))
8572        .stack_size(8 * 1024 * 1024)
8573        .build()
8574    {
8575        Ok(pool) => pool.install(run),
8576        Err(error) => {
8577            log::warn!(
8578                "callgraph store: bounded build pool unavailable ({error}); using global pool"
8579            );
8580            run()
8581        }
8582    };
8583
8584    let mut extracts = Vec::new();
8585    let mut failures = Vec::new();
8586    for result in results {
8587        match result {
8588            Ok(extract) => extracts.push(extract),
8589            Err(failure) => failures.push(failure),
8590        }
8591    }
8592    BuildExtractsResult { extracts, failures }
8593}
8594
8595fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
8596    let metadata = std::fs::metadata(path)?;
8597    let size = metadata.len();
8598    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
8599        cache_freshness::zero_hash()
8600    } else if source.len() as u64 == size {
8601        cache_freshness::hash_bytes(source.as_bytes())
8602    } else {
8603        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
8604    };
8605    Ok(FileFreshness {
8606        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
8607        size,
8608        content_hash,
8609    })
8610}
8611
8612fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
8613    let abs_path = normalize_file_path(project_root, path)?;
8614    let rel_path = relative_path(project_root, &abs_path);
8615    let source = std::fs::read_to_string(&abs_path)?;
8616    let freshness = collect_source_freshness(&abs_path, &source)?;
8617    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
8618    let lang = data.lang;
8619    if lang == LangId::Rust {
8620        extend_rust_imports_with_nested_uses(&source, &mut data);
8621    }
8622    let mut nodes = build_node_records(&rel_path, &source, &data)?;
8623    let node_by_scoped: HashMap<String, String> = nodes
8624        .iter()
8625        .map(|node| (node.scoped_name.clone(), node.id.clone()))
8626        .collect();
8627    let import_dependencies =
8628        import_dependencies(project_root, &abs_path, &data.import_block.imports);
8629    let line_index = LineIndex::new(&source);
8630    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
8631    let rust_reexports = if lang == LangId::Rust {
8632        collect_rust_pub_use_reexport_refs(
8633            project_root,
8634            &abs_path,
8635            &rel_path,
8636            &data.import_block.imports,
8637            &line_index,
8638        )
8639    } else {
8640        ReexportRefs {
8641            raw_refs: Vec::new(),
8642            surface_parts: Vec::new(),
8643        }
8644    };
8645    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
8646    let mut raw_refs = Vec::new();
8647    raw_refs.extend(build_call_refs(
8648        &rel_path,
8649        &data,
8650        &node_by_scoped,
8651        &import_dependencies,
8652    ));
8653    raw_refs.extend(build_value_ref_refs(
8654        &rel_path,
8655        &data,
8656        &node_by_scoped,
8657        &import_dependencies,
8658    ));
8659    raw_refs.extend(build_import_refs(
8660        project_root,
8661        &abs_path,
8662        &rel_path,
8663        &data.import_block.imports,
8664        &line_index,
8665    ));
8666    if lang == LangId::Rust {
8667        raw_refs.extend(build_rust_module_refs(
8668            project_root,
8669            &abs_path,
8670            &rel_path,
8671            &source,
8672        ));
8673    }
8674    let mut surface_parts = reexports.surface_parts;
8675    surface_parts.extend(rust_reexports.surface_parts);
8676    surface_parts.extend(source_less_exports.surface_parts);
8677    raw_refs.extend(reexports.raw_refs);
8678    raw_refs.extend(rust_reexports.raw_refs);
8679    raw_refs.extend(source_less_exports.raw_refs);
8680    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
8681    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
8682
8683    Ok(FileExtract {
8684        rel_path,
8685        freshness,
8686        lang,
8687        data,
8688        nodes,
8689        raw_refs,
8690        dispatch_hints,
8691        surface_fingerprint,
8692    })
8693}
8694
8695fn build_node_records(
8696    rel_path: &str,
8697    source: &str,
8698    data: &FileCallData,
8699) -> Result<Vec<NodeRecord>> {
8700    let mut records = Vec::new();
8701    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
8702    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
8703    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
8704
8705    for (scoped_name, meta) in metadata {
8706        let name = unqualified_name(scoped_name).to_string();
8707        let range = selection_range(source, scoped_name, &name, &meta.range);
8708        let range_key = (
8709            range.start_line,
8710            range.start_col,
8711            range.end_line,
8712            range.end_col,
8713        );
8714        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
8715        let range_ordinal = *ordinal;
8716        *ordinal += 1;
8717        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
8718        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
8719        let is_default_export = data
8720            .default_export_symbol
8721            .as_deref()
8722            .map(|default| default == scoped_name || default == name)
8723            .unwrap_or(false);
8724        records.push(NodeRecord {
8725            id,
8726            file_path: rel_path.to_string(),
8727            name: name.clone(),
8728            scoped_name: scoped_name.clone(),
8729            kind: symbol_kind_label(&meta.kind).to_string(),
8730            range,
8731            range_ordinal,
8732            signature: meta.signature.clone(),
8733            exported,
8734            is_default_export,
8735            is_type_like: is_type_like(&meta.kind),
8736            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
8737                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
8738        });
8739    }
8740
8741    Ok(records)
8742}
8743
8744fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
8745    if scoped_name == TOP_LEVEL_SYMBOL {
8746        return Range {
8747            start_line: 0,
8748            start_col: 0,
8749            end_line: 0,
8750            end_col: 0,
8751        };
8752    }
8753    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
8754        return fallback.clone();
8755    };
8756    let start_col = fallback.start_col as usize;
8757    let search_start = start_col.min(line.len());
8758    if let Some(offset) = line[search_start..].find(name) {
8759        let col = search_start + offset;
8760        return Range {
8761            start_line: fallback.start_line,
8762            start_col: col as u32,
8763            end_line: fallback.start_line,
8764            end_col: (col + name.len()) as u32,
8765        };
8766    }
8767    if let Some(offset) = line.find(name) {
8768        return Range {
8769            start_line: fallback.start_line,
8770            start_col: offset as u32,
8771            end_line: fallback.start_line,
8772            end_col: (offset + name.len()) as u32,
8773        };
8774    }
8775    Range {
8776        start_line: fallback.start_line,
8777        start_col: fallback.start_col,
8778        end_line: fallback.start_line,
8779        end_col: fallback.start_col.saturating_add(name.len() as u32),
8780    }
8781}
8782
8783fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
8784    if scoped_name == TOP_LEVEL_SYMBOL {
8785        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
8786    }
8787    let input = format!(
8788        "{rel_path}:{}:{}:{}:{}:{ordinal}",
8789        range.start_line, range.start_col, range.end_line, range.end_col
8790    );
8791    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
8792}
8793
8794fn build_call_refs(
8795    rel_path: &str,
8796    data: &FileCallData,
8797    node_by_scoped: &HashMap<String, String>,
8798    import_dependencies: &BTreeSet<String>,
8799) -> Vec<RawRef> {
8800    build_callable_refs(
8801        rel_path,
8802        &data.calls_by_symbol,
8803        node_by_scoped,
8804        import_dependencies,
8805        "call",
8806    )
8807}
8808
8809fn build_value_ref_refs(
8810    rel_path: &str,
8811    data: &FileCallData,
8812    node_by_scoped: &HashMap<String, String>,
8813    import_dependencies: &BTreeSet<String>,
8814) -> Vec<RawRef> {
8815    build_callable_refs(
8816        rel_path,
8817        &data.value_refs_by_symbol,
8818        node_by_scoped,
8819        import_dependencies,
8820        "value_ref",
8821    )
8822}
8823
8824fn build_callable_refs(
8825    rel_path: &str,
8826    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
8827    node_by_scoped: &HashMap<String, String>,
8828    import_dependencies: &BTreeSet<String>,
8829    kind: &str,
8830) -> Vec<RawRef> {
8831    let mut refs = Vec::new();
8832    let mut ordinal = 0usize;
8833    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
8834    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
8835    for (caller_symbol, call_sites) in symbols {
8836        let caller_node = node_by_scoped.get(caller_symbol).cloned();
8837        for call_site in call_sites {
8838            ordinal += 1;
8839            let ref_id = ref_id(&[
8840                rel_path,
8841                kind,
8842                caller_symbol,
8843                &call_site.line.to_string(),
8844                &call_site.byte_start.to_string(),
8845                &call_site.byte_end.to_string(),
8846                &call_site.full_callee,
8847                &ordinal.to_string(),
8848            ]);
8849            refs.push(RawRef {
8850                ref_id,
8851                caller_node: caller_node.clone(),
8852                caller_symbol: Some(caller_symbol.clone()),
8853                caller_file: rel_path.to_string(),
8854                kind: kind.to_string(),
8855                short_name: Some(call_site.callee_name.clone()),
8856                full_ref: Some(call_site.full_callee.clone()),
8857                module_path: None,
8858                import_kind: None,
8859                local_name: Some(call_site.callee_name.clone()),
8860                requested_name: Some(call_site.callee_name.clone()),
8861                namespace_alias: namespace_alias(&call_site.full_callee),
8862                wildcard: false,
8863                line: call_site.line,
8864                byte_start: call_site.byte_start,
8865                byte_end: call_site.byte_end,
8866                dependencies: import_dependencies.clone(),
8867            });
8868        }
8869    }
8870    refs
8871}
8872
8873fn build_import_refs(
8874    project_root: &Path,
8875    abs_path: &Path,
8876    rel_path: &str,
8877    imports: &[ImportStatement],
8878    line_index: &LineIndex,
8879) -> Vec<RawRef> {
8880    let mut refs = Vec::new();
8881    for (index, import) in imports.iter().enumerate() {
8882        let import_kind = import_kind_label(import.kind).to_string();
8883        let local_name = import_local_names(import).join(",");
8884        let requested_name = import_requested_names(import).join(",");
8885        let ref_id = ref_id(&[
8886            rel_path,
8887            "import",
8888            &import.byte_range.start.to_string(),
8889            &import.byte_range.end.to_string(),
8890            &import.module_path,
8891            &index.to_string(),
8892        ]);
8893        refs.push(RawRef {
8894            ref_id,
8895            caller_node: None,
8896            caller_symbol: None,
8897            caller_file: rel_path.to_string(),
8898            kind: "import".to_string(),
8899            short_name: None,
8900            full_ref: Some(import.raw_text.clone()),
8901            module_path: Some(import.module_path.clone()),
8902            import_kind: Some(import_kind),
8903            local_name: empty_to_none(local_name),
8904            requested_name: empty_to_none(requested_name),
8905            namespace_alias: import.namespace_import.clone(),
8906            wildcard: import_is_wildcard(import),
8907            line: line_index.byte_to_line(import.byte_range.start),
8908            byte_start: import.byte_range.start,
8909            byte_end: import.byte_range.end,
8910            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
8911        });
8912    }
8913    refs
8914}
8915
8916fn build_rust_module_refs(
8917    project_root: &Path,
8918    abs_path: &Path,
8919    rel_path: &str,
8920    source: &str,
8921) -> Vec<RawRef> {
8922    let grammar = grammar_for(LangId::Rust);
8923    let mut parser = Parser::new();
8924    if parser.set_language(&grammar).is_err() {
8925        return Vec::new();
8926    }
8927    let Some(tree) = parser.parse(source, None) else {
8928        return Vec::new();
8929    };
8930
8931    let mut refs = Vec::new();
8932    let mut stack = vec![tree.root_node()];
8933    while let Some(node) = stack.pop() {
8934        if node.kind() == "mod_item"
8935            && node
8936                .named_children(&mut node.walk())
8937                .all(|child| child.kind() != "declaration_list")
8938        {
8939            if let Some(name_node) = node.child_by_field_name("name") {
8940                let module_name = node_text(name_node, source).to_string();
8941                let target = rust_external_module_target(abs_path, source, node, &module_name);
8942                let mut dependencies = BTreeSet::new();
8943                if let Some(target) = target {
8944                    dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
8945                }
8946                refs.push(RawRef {
8947                    ref_id: ref_id(&[
8948                        rel_path,
8949                        "module",
8950                        &module_name,
8951                        &node.start_byte().to_string(),
8952                    ]),
8953                    caller_node: None,
8954                    caller_symbol: None,
8955                    caller_file: rel_path.to_string(),
8956                    kind: "module".to_string(),
8957                    short_name: Some(module_name.clone()),
8958                    full_ref: Some(module_name.clone()),
8959                    module_path: Some(module_name.clone()),
8960                    import_kind: Some("module".to_string()),
8961                    local_name: Some(module_name.clone()),
8962                    requested_name: Some(module_name),
8963                    namespace_alias: None,
8964                    wildcard: false,
8965                    line: node.start_position().row as u32 + 1,
8966                    byte_start: node.start_byte(),
8967                    byte_end: node.end_byte(),
8968                    dependencies,
8969                });
8970            }
8971        }
8972
8973        let mut cursor = node.walk();
8974        if cursor.goto_first_child() {
8975            loop {
8976                stack.push(cursor.node());
8977                if !cursor.goto_next_sibling() {
8978                    break;
8979                }
8980            }
8981        }
8982    }
8983    refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
8984    refs
8985}
8986
8987fn rust_declared_module_target(
8988    project_root: &Path,
8989    caller_file: &str,
8990    module_name: &str,
8991    memo: &callgraph::ModuleResolutionMemo,
8992) -> Option<String> {
8993    memo.rust_declared_module_target(caller_file, module_name, || {
8994        rust_declared_module_targets(project_root, caller_file)
8995    })
8996}
8997
8998fn rust_declared_module_targets(
8999    project_root: &Path,
9000    caller_file: &str,
9001) -> HashMap<String, Option<String>> {
9002    let declaring_file = project_root.join(caller_file);
9003    let Ok(source) = std::fs::read_to_string(&declaring_file) else {
9004        return HashMap::new();
9005    };
9006    let Ok(tree) = parse_source_with_cached_parser(&declaring_file, &source, LangId::Rust) else {
9007        return HashMap::new();
9008    };
9009    let mut targets = HashMap::new();
9010    let mut stack = vec![tree.root_node()];
9011    while let Some(node) = stack.pop() {
9012        if node.kind() == "mod_item"
9013            && node
9014                .named_children(&mut node.walk())
9015                .all(|child| child.kind() != "declaration_list")
9016        {
9017            if let Some(name) = node.child_by_field_name("name") {
9018                let module_name = node_text(name, &source);
9019                let target =
9020                    rust_external_module_target(&declaring_file, &source, node, module_name)
9021                        .map(|target| relative_path(project_root, &canonicalize_path(&target)));
9022                targets.entry(module_name.to_string()).or_insert(target);
9023            }
9024        }
9025        let mut cursor = node.walk();
9026        if cursor.goto_first_child() {
9027            loop {
9028                stack.push(cursor.node());
9029                if !cursor.goto_next_sibling() {
9030                    break;
9031                }
9032            }
9033        }
9034    }
9035    targets
9036}
9037
9038fn rust_external_module_target(
9039    declaring_file: &Path,
9040    source: &str,
9041    module: Node<'_>,
9042    module_name: &str,
9043) -> Option<PathBuf> {
9044    let parent = declaring_file.parent()?;
9045    let mut previous = module.prev_sibling();
9046    while let Some(attribute) = previous {
9047        if attribute.kind() != "attribute_item" {
9048            break;
9049        }
9050        let text = source.get(attribute.byte_range())?;
9051        if let Some(path) = rust_path_attribute(text) {
9052            let candidate = parent.join(path);
9053            return candidate.is_file().then_some(candidate);
9054        }
9055        previous = attribute.prev_sibling();
9056    }
9057
9058    let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
9059    let module_dir = if matches!(stem, "lib" | "main" | "mod") {
9060        parent.to_path_buf()
9061    } else {
9062        parent.join(stem)
9063    };
9064    [
9065        module_dir.join(format!("{module_name}.rs")),
9066        module_dir.join(module_name).join("mod.rs"),
9067    ]
9068    .into_iter()
9069    .find(|candidate| candidate.is_file())
9070}
9071
9072fn rust_path_attribute(attribute: &str) -> Option<&str> {
9073    let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
9074    let (name, value) = body.split_once('=')?;
9075    (name.trim() == "path")
9076        .then(|| value.trim().trim_matches('"'))
9077        .filter(|path| !path.is_empty())
9078}
9079
9080fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
9081    let grammar = grammar_for(LangId::Rust);
9082    let mut parser = Parser::new();
9083    if parser.set_language(&grammar).is_err() {
9084        return;
9085    }
9086    let Some(tree) = parser.parse(source, None) else {
9087        return;
9088    };
9089
9090    let mut seen = data
9091        .import_block
9092        .imports
9093        .iter()
9094        .map(|import| (import.byte_range.start, import.byte_range.end))
9095        .collect::<HashSet<_>>();
9096    let mut nested_imports = Vec::new();
9097    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9098    if nested_imports.is_empty() {
9099        return;
9100    }
9101
9102    data.import_block.imports.extend(nested_imports);
9103    data.import_block
9104        .imports
9105        .sort_by_key(|import| import.byte_range.start);
9106    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9107}
9108
9109fn collect_rust_use_imports(
9110    source: &str,
9111    node: Node<'_>,
9112    seen: &mut HashSet<(usize, usize)>,
9113    imports: &mut Vec<ImportStatement>,
9114) {
9115    if node.kind() == "use_declaration" {
9116        let range = node.byte_range();
9117        if seen.insert((range.start, range.end)) {
9118            if let Some(import) = rust_import_from_use_node(source, node) {
9119                imports.push(import);
9120            }
9121        }
9122    }
9123
9124    let mut cursor = node.walk();
9125    if !cursor.goto_first_child() {
9126        return;
9127    }
9128    loop {
9129        collect_rust_use_imports(source, cursor.node(), seen, imports);
9130        if !cursor.goto_next_sibling() {
9131            break;
9132        }
9133    }
9134}
9135
9136fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9137    let raw_text = source[node.byte_range()].to_string();
9138    let body = rust_use_body(&raw_text)?.to_string();
9139    let visibility = rust_use_visibility(&raw_text);
9140    let names = rust_use_list_names(&body);
9141    let group = classify_rust_import_group(&body);
9142    let byte_range = node.byte_range();
9143
9144    Some(ImportStatement {
9145        module_path: body,
9146        names: names.clone(),
9147        default_import: visibility.clone(),
9148        namespace_import: None,
9149        kind: ImportKind::Value,
9150        group,
9151        byte_range,
9152        raw_text,
9153        form: ImportForm::RustUse {
9154            visibility,
9155            named: names,
9156        },
9157    })
9158}
9159
9160fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9161    let start = imports.iter().map(|import| import.byte_range.start).min()?;
9162    let end = imports.iter().map(|import| import.byte_range.end).max()?;
9163    Some(start..end)
9164}
9165
9166fn rust_use_visibility(raw_text: &str) -> Option<String> {
9167    let use_pos = raw_text.find("use ")?;
9168    let prefix = raw_text[..use_pos].trim();
9169    if prefix.is_empty() {
9170        None
9171    } else {
9172        Some(prefix.to_string())
9173    }
9174}
9175
9176fn rust_use_body(raw_text: &str) -> Option<&str> {
9177    let use_pos = raw_text.find("use ")?;
9178    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9179}
9180
9181fn rust_use_list_names(body: &str) -> Vec<String> {
9182    let Some(open) = body.find("::{") else {
9183        return Vec::new();
9184    };
9185    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9186        return Vec::new();
9187    };
9188    body[open + 3..close]
9189        .split(',')
9190        .filter_map(|spec| {
9191            let spec = spec.trim();
9192            if spec.is_empty() {
9193                None
9194            } else {
9195                Some(spec.to_string())
9196            }
9197        })
9198        .collect()
9199}
9200
9201fn classify_rust_import_group(body: &str) -> ImportGroup {
9202    let first = body
9203        .split("::")
9204        .next()
9205        .unwrap_or(body)
9206        .split_whitespace()
9207        .next()
9208        .unwrap_or(body);
9209    match first.trim() {
9210        "std" | "core" | "alloc" => ImportGroup::Stdlib,
9211        "crate" | "self" | "super" => ImportGroup::Internal,
9212        _ => ImportGroup::External,
9213    }
9214}
9215
9216#[derive(Debug, Clone)]
9217struct ReexportRefs {
9218    raw_refs: Vec<RawRef>,
9219    surface_parts: Vec<String>,
9220}
9221
9222fn collect_reexport_refs(
9223    project_root: &Path,
9224    abs_path: &Path,
9225    rel_path: &str,
9226    source: &str,
9227) -> ReexportRefs {
9228    let mut raw_refs = Vec::new();
9229    let mut surface_parts = Vec::new();
9230    let mut search_start = 0usize;
9231    let mut ordinal = 0usize;
9232    while let Some(export_offset) = source[search_start..].find("export") {
9233        let start = search_start + export_offset;
9234        let Some(statement_end_offset) = source[start..].find(';') else {
9235            break;
9236        };
9237        let end = start + statement_end_offset + 1;
9238        let statement = &source[start..end];
9239        search_start = end;
9240        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9241            continue;
9242        }
9243        let Some(module_path) = quoted_module_path(statement) else {
9244            continue;
9245        };
9246        ordinal += 1;
9247        let wildcard = statement.contains('*');
9248        let line = source[..start]
9249            .bytes()
9250            .filter(|byte| *byte == b'\n')
9251            .count() as u32
9252            + 1;
9253        let ref_id = ref_id(&[
9254            rel_path,
9255            "reexport",
9256            &start.to_string(),
9257            &end.to_string(),
9258            &module_path,
9259            &ordinal.to_string(),
9260        ]);
9261        surface_parts.push(format!("reexport\t{statement}"));
9262        raw_refs.push(RawRef {
9263            ref_id,
9264            caller_node: None,
9265            caller_symbol: None,
9266            caller_file: rel_path.to_string(),
9267            kind: "reexport".to_string(),
9268            short_name: None,
9269            full_ref: Some(statement.to_string()),
9270            module_path: Some(module_path.clone()),
9271            import_kind: Some("reexport".to_string()),
9272            local_name: None,
9273            requested_name: None,
9274            namespace_alias: None,
9275            wildcard,
9276            line,
9277            byte_start: start,
9278            byte_end: end,
9279            dependencies: module_dependencies(project_root, abs_path, &module_path),
9280        });
9281    }
9282    ReexportRefs {
9283        raw_refs,
9284        surface_parts,
9285    }
9286}
9287
9288fn collect_rust_pub_use_reexport_refs(
9289    project_root: &Path,
9290    abs_path: &Path,
9291    rel_path: &str,
9292    imports: &[ImportStatement],
9293    line_index: &LineIndex,
9294) -> ReexportRefs {
9295    let mut raw_refs = Vec::new();
9296    let mut surface_parts = Vec::new();
9297    let mut ordinal = 0usize;
9298
9299    for import in imports {
9300        let Some(visibility) = &import.default_import else {
9301            continue;
9302        };
9303        if !visibility.starts_with("pub") {
9304            continue;
9305        }
9306        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9307            continue;
9308        };
9309        ordinal += 1;
9310        let ref_id = ref_id(&[
9311            rel_path,
9312            "rust_reexport",
9313            &import.byte_range.start.to_string(),
9314            &import.byte_range.end.to_string(),
9315            &module_path,
9316            &ordinal.to_string(),
9317        ]);
9318        surface_parts.push(format!("reexport\t{}", import.raw_text));
9319        raw_refs.push(RawRef {
9320            ref_id,
9321            caller_node: None,
9322            caller_symbol: None,
9323            caller_file: rel_path.to_string(),
9324            kind: "reexport".to_string(),
9325            short_name: None,
9326            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9327            module_path: Some(module_path.clone()),
9328            import_kind: Some("reexport".to_string()),
9329            local_name: None,
9330            requested_name: None,
9331            namespace_alias: None,
9332            wildcard,
9333            line: line_index.byte_to_line(import.byte_range.start),
9334            byte_start: import.byte_range.start,
9335            byte_end: import.byte_range.end,
9336            dependencies: rust_module_dependencies(project_root, abs_path, &module_path),
9337        });
9338    }
9339
9340    ReexportRefs {
9341        raw_refs,
9342        surface_parts,
9343    }
9344}
9345
9346fn rust_pub_use_reexport_parts(
9347    import: &ImportStatement,
9348) -> Option<(String, HashMap<String, String>, bool)> {
9349    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9350    let body = body.trim();
9351    if let Some(module_path) = body.strip_suffix("::*") {
9352        return Some((module_path.trim().to_string(), HashMap::new(), true));
9353    }
9354
9355    if let Some(brace_start) = body.find("::{") {
9356        let module_path = body[..brace_start].trim().to_string();
9357        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9358        if names.is_empty() {
9359            return None;
9360        }
9361        return Some((module_path, names, false));
9362    }
9363
9364    let (module_path, spec) = body.rsplit_once("::")?;
9365    let names = rust_reexport_names_from_specs(spec);
9366    if names.is_empty() {
9367        return None;
9368    }
9369    Some((module_path.trim().to_string(), names, false))
9370}
9371
9372fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9373    let mut names = HashMap::new();
9374    for spec in specs.split(',') {
9375        let spec = spec.trim();
9376        if spec.is_empty() || spec == "self" {
9377            continue;
9378        }
9379        if let Some((source, local)) = spec.split_once(" as ") {
9380            let source = source.trim();
9381            let local = local.trim();
9382            if !source.is_empty() && !local.is_empty() && source != "self" {
9383                names.insert(local.to_string(), source.to_string());
9384            }
9385        } else {
9386            names.insert(spec.to_string(), spec.to_string());
9387        }
9388    }
9389    names
9390}
9391
9392fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9393    if named.is_empty() {
9394        return fallback.to_string();
9395    }
9396    let mut specs = named
9397        .iter()
9398        .map(|(local, source)| {
9399            if local == source {
9400                source.clone()
9401            } else {
9402                format!("{source} as {local}")
9403            }
9404        })
9405        .collect::<Vec<_>>();
9406    specs.sort();
9407    format!("pub use {{{}}};", specs.join(", "))
9408}
9409
9410fn quoted_module_path(statement: &str) -> Option<String> {
9411    let quote = match (statement.find('\''), statement.find('"')) {
9412        (Some(single), Some(double)) if single < double => '\'',
9413        (Some(_), Some(_)) => '"',
9414        (Some(_), None) => '\'',
9415        (None, Some(_)) => '"',
9416        (None, None) => return None,
9417    };
9418    let start = statement.find(quote)? + 1;
9419    let end = statement[start..].find(quote)? + start;
9420    Some(statement[start..end].to_string())
9421}
9422
9423#[derive(Debug, Clone)]
9424struct SourceLessExportRefs {
9425    raw_refs: Vec<RawRef>,
9426    surface_parts: Vec<String>,
9427}
9428
9429fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9430    let mut raw_refs = Vec::new();
9431    let mut surface_parts = Vec::new();
9432    let mut search_start = 0usize;
9433    let mut ordinal = 0usize;
9434    while let Some(export_offset) = source[search_start..].find("export") {
9435        let start = search_start + export_offset;
9436        let Some(statement_end_offset) = source[start..].find(';') else {
9437            break;
9438        };
9439        let end = start + statement_end_offset + 1;
9440        let statement = &source[start..end];
9441        search_start = end;
9442        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9443            continue;
9444        }
9445        let aliases = parse_reexport_names(statement);
9446        if aliases.is_empty() {
9447            continue;
9448        }
9449        let line = source[..start]
9450            .bytes()
9451            .filter(|byte| *byte == b'\n')
9452            .count() as u32
9453            + 1;
9454        for (exported, source_symbol) in aliases {
9455            ordinal += 1;
9456            let ref_id = ref_id(&[
9457                rel_path,
9458                "export_alias",
9459                &start.to_string(),
9460                &end.to_string(),
9461                &exported,
9462                &source_symbol,
9463                &ordinal.to_string(),
9464            ]);
9465            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9466            raw_refs.push(RawRef {
9467                ref_id,
9468                caller_node: None,
9469                caller_symbol: None,
9470                caller_file: rel_path.to_string(),
9471                kind: "export_alias".to_string(),
9472                short_name: None,
9473                full_ref: Some(statement.to_string()),
9474                module_path: None,
9475                import_kind: Some("export_alias".to_string()),
9476                local_name: Some(exported),
9477                requested_name: Some(source_symbol),
9478                namespace_alias: None,
9479                wildcard: false,
9480                line,
9481                byte_start: start,
9482                byte_end: end,
9483                dependencies: BTreeSet::new(),
9484            });
9485        }
9486    }
9487    SourceLessExportRefs {
9488        raw_refs,
9489        surface_parts,
9490    }
9491}
9492
9493fn build_dispatch_hints(
9494    rel_path: &str,
9495    data: &FileCallData,
9496    node_by_scoped: &HashMap<String, String>,
9497) -> Vec<DispatchHint> {
9498    let mut hints = Vec::new();
9499    let mut ordinal = 0usize;
9500    for (caller_symbol, call_sites) in &data.calls_by_symbol {
9501        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
9502            continue;
9503        };
9504        for call_site in call_sites {
9505            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
9506                continue;
9507            }
9508            ordinal += 1;
9509            hints.push(DispatchHint {
9510                id: ref_id(&[
9511                    rel_path,
9512                    "dispatch",
9513                    caller_symbol,
9514                    &call_site.line.to_string(),
9515                    &call_site.byte_start.to_string(),
9516                    &call_site.byte_end.to_string(),
9517                    &ordinal.to_string(),
9518                ]),
9519                method_name: call_site.callee_name.clone(),
9520                caller_node: caller_node.clone(),
9521                file: rel_path.to_string(),
9522                line: call_site.line,
9523                byte_start: call_site.byte_start,
9524                byte_end: call_site.byte_end,
9525            });
9526        }
9527    }
9528    hints
9529}
9530
9531fn surface_fingerprint(
9532    nodes: &mut [NodeRecord],
9533    data: &FileCallData,
9534    reexport_parts: &[String],
9535) -> String {
9536    nodes.sort_by(|left, right| {
9537        (left.file_path.as_str(), left.scoped_name.as_str())
9538            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
9539    });
9540    let mut parts = Vec::new();
9541    for node in nodes.iter() {
9542        parts.push(format!(
9543            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
9544            node.scoped_name,
9545            node.name,
9546            node.kind,
9547            node.exported,
9548            node.range.start_line,
9549            node.range.start_col,
9550            node.range.end_line,
9551            node.range.end_col,
9552            node.range_ordinal,
9553            node.signature.as_deref().unwrap_or("")
9554        ));
9555    }
9556    let mut exports = data.exported_symbols.clone();
9557    exports.sort();
9558    for export in exports {
9559        parts.push(format!("export\t{export}"));
9560    }
9561    if let Some(default_export) = &data.default_export_symbol {
9562        parts.push(format!("default\t{default_export}"));
9563    }
9564    let mut imports: Vec<String> = data
9565        .import_block
9566        .imports
9567        .iter()
9568        .map(|import| {
9569            format!(
9570                "import\t{}\t{:?}\t{}",
9571                import.module_path, import.form, import.raw_text
9572            )
9573        })
9574        .collect();
9575    imports.sort();
9576    parts.extend(imports);
9577    parts.extend(reexport_parts.iter().cloned());
9578    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
9579}
9580
9581fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
9582    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
9583        return Ok(ResolvedRef {
9584            dependencies: raw.dependencies.clone(),
9585            raw,
9586            status: "unresolved".to_string(),
9587            target_node: None,
9588            target_file: None,
9589            target_symbol: None,
9590            edge: None,
9591        });
9592    }
9593
9594    let caller_file = raw.caller_file.clone();
9595    let caller_data =
9596        index
9597            .caller_data(&caller_file)
9598            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
9599                file: caller_file.clone(),
9600            })?;
9601    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
9602    let short_name = raw.short_name.as_deref().unwrap_or_default();
9603    let mut dependencies = raw.dependencies.clone();
9604
9605    let resolved = match index.lang_for(&caller_file) {
9606        Some(LangId::Rust) => {
9607            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
9608        }
9609        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
9610            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
9611        }
9612        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
9613    };
9614
9615    let Some((status, target_file, target_symbol)) = resolved else {
9616        return Ok(ResolvedRef {
9617            raw,
9618            status: "unresolved".to_string(),
9619            target_node: None,
9620            target_file: None,
9621            target_symbol: None,
9622            dependencies,
9623            edge: None,
9624        });
9625    };
9626
9627    dependencies.insert(target_file.clone());
9628    let target_node = index.node_for_symbol(&target_file, &target_symbol);
9629    if raw.kind == "value_ref"
9630        && !target_node
9631            .as_deref()
9632            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
9633    {
9634        return Ok(ResolvedRef {
9635            raw,
9636            status: "unresolved".to_string(),
9637            target_node: None,
9638            target_file: None,
9639            target_symbol: None,
9640            dependencies,
9641            edge: None,
9642        });
9643    }
9644    let source_node = raw.caller_node.clone();
9645    let edge = if let Some(source_node) = source_node {
9646        if target_file == caller_file
9647            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
9648        {
9649            None
9650        } else {
9651            Some(EdgeRecord {
9652                edge_id: ref_id(&[&raw.ref_id, "edge"]),
9653                source_node,
9654                target_node: target_node.clone(),
9655                target_file: target_file.clone(),
9656                target_symbol: target_symbol.clone(),
9657                kind: raw.kind.clone(),
9658                line: raw.line,
9659            })
9660        }
9661    } else {
9662        None
9663    };
9664
9665    Ok(ResolvedRef {
9666        raw,
9667        status,
9668        target_node,
9669        target_file: Some(target_file),
9670        target_symbol: Some(target_symbol),
9671        dependencies,
9672        edge,
9673    })
9674}
9675
9676fn resolve_js_ts_target<I: ResolverIndex>(
9677    index: &I,
9678    caller_file: &str,
9679    full_ref: &str,
9680    short_name: &str,
9681    caller_data: &FileCallData,
9682) -> Option<(String, String, String)> {
9683    if let Some((namespace, member)) = full_ref.split_once('.') {
9684        for import in &caller_data.import_block.imports {
9685            if import.namespace_import.as_deref() == Some(namespace) {
9686                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9687                    if let Some((file, symbol)) =
9688                        resolve_exported_symbol(index, &target_file, member, 0)
9689                    {
9690                        return Some(("resolved".to_string(), file, symbol));
9691                    }
9692                }
9693            }
9694        }
9695    }
9696
9697    for import in &caller_data.import_block.imports {
9698        for spec in &import.names {
9699            if crate::imports::specifier_local_name(spec) == short_name {
9700                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9701                    let requested = crate::imports::specifier_imported_name(spec);
9702                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
9703                        .unwrap_or_else(|| (target_file, requested.to_string()));
9704                    return Some(("resolved".to_string(), file, symbol));
9705                }
9706            }
9707        }
9708
9709        if import.default_import.as_deref() == Some(short_name) {
9710            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9711                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
9712                    .or_else(|| {
9713                        index
9714                            .default_export(&target_file)
9715                            .map(|symbol| (target_file.clone(), symbol))
9716                    })
9717                    .unwrap_or_else(|| {
9718                        let file_name = Path::new(&target_file)
9719                            .file_name()
9720                            .and_then(|name| name.to_str())
9721                            .unwrap_or("unknown")
9722                            .to_string();
9723                        (target_file, format!("<default:{file_name}>"))
9724                    });
9725                return Some(("resolved".to_string(), file, symbol));
9726            }
9727        }
9728    }
9729
9730    for import in &caller_data.import_block.imports {
9731        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
9732            if index.has_export(&target_file, short_name) {
9733                return Some(("resolved".to_string(), target_file, short_name.to_string()));
9734            }
9735        }
9736    }
9737
9738    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9739}
9740
9741fn resolve_exported_symbol<I: ResolverIndex>(
9742    index: &I,
9743    file: &str,
9744    requested: &str,
9745    depth: usize,
9746) -> Option<(String, String)> {
9747    let mut visited = std::collections::HashMap::new();
9748    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
9749}
9750
9751/// Re-export graphs are frequently cyclic (barrel files re-exporting each
9752/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
9753/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
9754/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
9755/// (file, symbol) pair — but only when the earlier visit had at least as much
9756/// remaining depth budget (a shallower re-visit can reach leaves the deeper
9757/// first visit had to cut off at the cap, so plain visited-set pruning would
9758/// lose resolutions the capped walk finds).
9759fn resolve_exported_symbol_inner<I: ResolverIndex>(
9760    index: &I,
9761    file: &str,
9762    requested: &str,
9763    depth: usize,
9764    visited: &mut std::collections::HashMap<(String, String), usize>,
9765) -> Option<(String, String)> {
9766    if depth > 16 {
9767        return None;
9768    }
9769    if requested != "default" {
9770        if let Some(source_symbol) = index.export_alias(file, requested) {
9771            return Some((file.to_string(), source_symbol));
9772        }
9773        if index.has_export(file, requested) {
9774            return Some((file.to_string(), requested.to_string()));
9775        }
9776    } else if let Some(default) = index.default_export(file) {
9777        return Some((file.to_string(), default));
9778    }
9779
9780    // Memo check sits after the local-export fast paths: the common direct
9781    // hit never allocates the key, and a hit through the memo would have
9782    // returned above anyway.
9783    match visited.entry((file.to_string(), requested.to_string())) {
9784        std::collections::hash_map::Entry::Occupied(mut seen) => {
9785            if *seen.get() <= depth {
9786                return None;
9787            }
9788            seen.insert(depth);
9789        }
9790        std::collections::hash_map::Entry::Vacant(slot) => {
9791            slot.insert(depth);
9792        }
9793    }
9794
9795    for reexport in index.reexports_for(file) {
9796        let mut next_requested = requested.to_string();
9797        let matches = if reexport.wildcard {
9798            true
9799        } else if let Some(source_name) = reexport.named.get(requested) {
9800            next_requested = source_name.clone();
9801            true
9802        } else {
9803            false
9804        };
9805        if !matches {
9806            continue;
9807        }
9808        if let Some(target_file) = &reexport.target_file {
9809            if let Some(target) = resolve_exported_symbol_inner(
9810                index,
9811                target_file,
9812                &next_requested,
9813                depth + 1,
9814                visited,
9815            ) {
9816                return Some(target);
9817            }
9818        }
9819    }
9820    None
9821}
9822
9823fn resolve_rust_target<I: ResolverIndex>(
9824    index: &I,
9825    caller_file: &str,
9826    full_ref: &str,
9827    short_name: &str,
9828    caller_data: &FileCallData,
9829    raw: &RawRef,
9830) -> Option<(String, String, String)> {
9831    if full_ref.contains("::") {
9832        if let Some((target_file, target_symbol)) =
9833            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
9834        {
9835            return Some(("resolved".to_string(), target_file, target_symbol));
9836        }
9837    }
9838
9839    for import in &caller_data.import_block.imports {
9840        if let Some((target_file, target_symbol)) =
9841            rust_target_for_use(index, caller_file, import, short_name)
9842        {
9843            return Some(("resolved".to_string(), target_file, target_symbol));
9844        }
9845    }
9846
9847    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
9848}
9849
9850fn rust_target_for_qualified<I: ResolverIndex>(
9851    index: &I,
9852    caller_file: &str,
9853    full_ref: &str,
9854    short_name: &str,
9855    caller_data: &FileCallData,
9856    raw: &RawRef,
9857) -> Option<(String, String)> {
9858    let mut segments: Vec<&str> = full_ref.split("::").collect();
9859    if segments.len() < 2 {
9860        return None;
9861    }
9862    segments.pop();
9863    let requested_symbol = rust_target_symbol(full_ref, short_name);
9864
9865    for path in rust_module_path_candidates(&segments, caller_data, raw) {
9866        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
9867        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
9868            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
9869                return Some(rust_resolve_reexport_if_symbol_missing(
9870                    index,
9871                    target_file,
9872                    requested_symbol.clone(),
9873                ));
9874            }
9875        }
9876
9877        let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
9878        if let Some(target) =
9879            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
9880        {
9881            return Some(target);
9882        }
9883        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
9884            return Some(rust_resolve_reexport_if_symbol_missing(
9885                index,
9886                target_file,
9887                requested_symbol.clone(),
9888            ));
9889        }
9890    }
9891    None
9892}
9893
9894fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
9895    full_ref
9896        .rsplit("::")
9897        .next()
9898        .filter(|name| !name.is_empty())
9899        .unwrap_or(short_name)
9900        .to_string()
9901}
9902
9903fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
9904    index: &I,
9905    target_file: String,
9906    target_symbol: String,
9907) -> (String, String) {
9908    if index
9909        .node_for_symbol(&target_file, &target_symbol)
9910        .is_some()
9911    {
9912        return (target_file, target_symbol);
9913    }
9914    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
9915        resolved
9916    } else {
9917        (target_file, target_symbol)
9918    }
9919}
9920
9921fn rust_module_path_candidates(
9922    segments: &[&str],
9923    caller_data: &FileCallData,
9924    raw: &RawRef,
9925) -> Vec<Vec<String>> {
9926    let mut candidates = Vec::new();
9927    if let Some(first) = segments.first().copied() {
9928        for import in &caller_data.import_block.imports {
9929            if !rust_import_is_visible_to_call(import, raw) {
9930                continue;
9931            }
9932            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
9933                continue;
9934            };
9935            if local_name == first {
9936                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
9937                rust_push_unique_path_candidate(&mut candidates, path_segments);
9938            }
9939        }
9940    }
9941    rust_push_unique_path_candidate(
9942        &mut candidates,
9943        segments
9944            .iter()
9945            .map(|segment| (*segment).to_string())
9946            .collect(),
9947    );
9948    candidates
9949}
9950
9951fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
9952    if !candidates.iter().any(|existing| existing == &candidate) {
9953        candidates.push(candidate);
9954    }
9955}
9956
9957fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
9958    import.byte_range.start <= raw.byte_start
9959}
9960
9961fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
9962    let path = import.module_path.trim().trim_end_matches(';').trim();
9963    if path.contains("::{") || path.contains('{') || path.contains('*') {
9964        return None;
9965    }
9966    let (path_without_alias, alias) = path
9967        .split_once(" as ")
9968        .map(|(left, right)| (left.trim(), Some(right.trim())))
9969        .unwrap_or((path, None));
9970    let segments = path_without_alias
9971        .split("::")
9972        .map(str::trim)
9973        .filter(|segment| !segment.is_empty())
9974        .collect::<Vec<_>>();
9975    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
9976    if local_name.chars().next().is_some_and(char::is_uppercase) {
9977        return None;
9978    }
9979    Some((
9980        local_name,
9981        segments
9982            .into_iter()
9983            .map(|segment| segment.to_string())
9984            .collect(),
9985    ))
9986}
9987
9988fn rust_inline_scoped_target<I: ResolverIndex>(
9989    index: &I,
9990    caller_file: &str,
9991    module_segments: &[String],
9992    short_name: &str,
9993) -> Option<(String, String)> {
9994    index.inline_scoped_target(caller_file, module_segments, short_name)
9995}
9996
9997fn rust_target_for_use<I: ResolverIndex>(
9998    index: &I,
9999    caller_file: &str,
10000    import: &ImportStatement,
10001    short_name: &str,
10002) -> Option<(String, String)> {
10003    let path = import.module_path.trim().trim_end_matches(';');
10004    if let Some(brace_start) = path.find("::{") {
10005        let prefix = &path[..brace_start];
10006        if import.names.iter().any(|name| name == short_name) {
10007            let prefix_segments: Vec<&str> = prefix.split("::").collect();
10008            let module_segments =
10009                rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
10010            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10011            return Some((file, short_name.to_string()));
10012        }
10013        return None;
10014    }
10015
10016    let (path_without_alias, alias) = path
10017        .split_once(" as ")
10018        .map(|(left, right)| (left.trim(), Some(right.trim())))
10019        .unwrap_or((path, None));
10020    let segments: Vec<&str> = path_without_alias.split("::").collect();
10021    let imported = alias.or_else(|| segments.last().copied())?;
10022    if imported != short_name {
10023        return None;
10024    }
10025    if segments.len() < 2 {
10026        return None;
10027    }
10028    let module_segments =
10029        rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
10030    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10031    Some((file, segments.last().unwrap_or(&short_name).to_string()))
10032}
10033
10034fn rust_workspace_file_for_segments<I: ResolverIndex>(
10035    index: &I,
10036    segments: &[&str],
10037) -> Option<String> {
10038    let crate_name = segments.first().copied()?;
10039    let src_prefix = index.crate_src_prefix(crate_name)?;
10040    let module_segments = segments[1..]
10041        .iter()
10042        .map(|segment| segment.to_string())
10043        .collect::<Vec<_>>();
10044    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
10045}
10046
10047#[cfg(test)]
10048static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
10049    OnceLock::new();
10050
10051#[cfg(test)]
10052fn note_workspace_crate_prefix_build(project_root: &Path) {
10053    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10054        .get_or_init(|| Mutex::new(HashMap::new()))
10055        .lock()
10056        .expect("workspace crate prefix build counts mutex poisoned");
10057    *counts.entry(project_root.to_path_buf()).or_default() += 1;
10058}
10059
10060#[cfg(not(test))]
10061fn note_workspace_crate_prefix_build(_project_root: &Path) {}
10062
10063#[cfg(test)]
10064fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
10065    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10066        .get_or_init(|| Mutex::new(HashMap::new()))
10067        .lock()
10068        .expect("workspace crate prefix build counts mutex poisoned")
10069        .remove(project_root);
10070}
10071
10072#[cfg(test)]
10073fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
10074    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10075        .get_or_init(|| Mutex::new(HashMap::new()))
10076        .lock()
10077        .expect("workspace crate prefix build counts mutex poisoned")
10078        .get(project_root)
10079        .copied()
10080        .unwrap_or(0)
10081}
10082
10083/// Walk the project tree once and map every Rust crate name (package name with
10084/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
10085/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
10086/// longer re-walks the filesystem once per ref.
10087fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
10088    note_workspace_crate_prefix_build(project_root);
10089    let mut prefixes = HashMap::new();
10090    let mut stack = vec![project_root.to_path_buf()];
10091    while let Some(dir) = stack.pop() {
10092        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
10093        if matches!(name, "target" | "node_modules" | ".git") {
10094            continue;
10095        }
10096        let manifest = dir.join("Cargo.toml");
10097        if manifest.is_file() {
10098            let crate_names = rust_manifest_crate_names(&manifest);
10099            if !crate_names.is_empty() {
10100                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
10101                for crate_name in crate_names {
10102                    prefixes
10103                        .entry(crate_name)
10104                        .or_insert_with(|| src_prefix.clone());
10105                }
10106            }
10107        }
10108        let Ok(entries) = std::fs::read_dir(&dir) else {
10109            continue;
10110        };
10111        for entry in entries.flatten() {
10112            let path = entry.path();
10113            if path.is_dir() {
10114                stack.push(path);
10115            }
10116        }
10117    }
10118    prefixes
10119}
10120
10121/// Extract the crate names a manifest defines: the normalized package name
10122/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
10123/// reachable by either spelling, matching the previous match semantics.
10124fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
10125    let Ok(source) = std::fs::read_to_string(manifest) else {
10126        return Vec::new();
10127    };
10128    let mut in_lib = false;
10129    let mut package_name = None;
10130    let mut lib_name = None;
10131    for line in source.lines() {
10132        let trimmed = line.trim();
10133        if trimmed.starts_with('[') {
10134            in_lib = trimmed == "[lib]";
10135            continue;
10136        }
10137        let Some((key, value)) = trimmed.split_once('=') else {
10138            continue;
10139        };
10140        let key = key.trim();
10141        let value = value.trim().trim_matches('"');
10142        if in_lib && key == "name" {
10143            lib_name = Some(value.to_string());
10144        } else if !in_lib && key == "name" && package_name.is_none() {
10145            package_name = Some(value.to_string());
10146        }
10147    }
10148    let mut names = Vec::new();
10149    if let Some(lib) = lib_name {
10150        names.push(lib);
10151    }
10152    if let Some(package) = package_name {
10153        let normalized = package.replace('-', "_");
10154        if !names.contains(&normalized) {
10155            names.push(normalized);
10156        }
10157    }
10158    names
10159}
10160
10161fn rust_resolve_segments_with_index<I: ResolverIndex>(
10162    index: &I,
10163    caller_file: &str,
10164    segments: &[&str],
10165) -> Option<Vec<String>> {
10166    let caller_segments = rust_registered_module_segments(index, caller_file)
10167        .unwrap_or_else(|| rust_module_segments_for_rel(caller_file));
10168    rust_resolve_segments_from(caller_segments, segments)
10169}
10170
10171fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10172    rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10173}
10174
10175fn rust_resolve_segments_from(
10176    caller_segments: Vec<String>,
10177    segments: &[&str],
10178) -> Option<Vec<String>> {
10179    if segments.is_empty() {
10180        return Some(Vec::new());
10181    }
10182    match segments[0] {
10183        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10184        "self" => {
10185            let mut resolved = caller_segments;
10186            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10187            Some(resolved)
10188        }
10189        "super" => {
10190            let mut resolved = caller_segments;
10191            resolved.pop();
10192            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10193            Some(resolved)
10194        }
10195        _ => {
10196            let mut resolved = caller_segments;
10197            resolved.pop();
10198            resolved.extend(segments.iter().map(|item| item.to_string()));
10199            Some(resolved)
10200        }
10201    }
10202}
10203
10204fn rust_registered_module_segments<I: ResolverIndex>(
10205    index: &I,
10206    caller_file: &str,
10207) -> Option<Vec<String>> {
10208    let mut current = caller_file.to_string();
10209    let mut segments = Vec::new();
10210    let mut seen = HashSet::new();
10211    while seen.insert(current.clone()) {
10212        let Some((parent, module)) = index.module_parent(&current) else {
10213            break;
10214        };
10215        segments.push(module);
10216        current = parent;
10217    }
10218    if segments.is_empty() {
10219        None
10220    } else {
10221        segments.reverse();
10222        Some(segments)
10223    }
10224}
10225
10226fn rust_file_for_segments<I: ResolverIndex>(
10227    index: &I,
10228    caller_file: &str,
10229    segments: &[String],
10230) -> Option<String> {
10231    let src_prefix = rust_src_prefix(caller_file);
10232    if let Some(target) = rust_file_from_module_declarations(index, &src_prefix, segments) {
10233        return Some(target);
10234    }
10235    rust_file_for_src_prefix(index, &src_prefix, segments)
10236}
10237
10238fn rust_file_from_module_declarations<I: ResolverIndex>(
10239    index: &I,
10240    src_prefix: &str,
10241    segments: &[String],
10242) -> Option<String> {
10243    let mut current = [
10244        format!("{src_prefix}/lib.rs"),
10245        format!("{src_prefix}/main.rs"),
10246    ]
10247    .into_iter()
10248    .find(|candidate| index.contains_file(candidate))?;
10249    for segment in segments {
10250        current = index.module_target(&current, segment)?;
10251    }
10252    Some(current)
10253}
10254
10255fn rust_file_for_src_prefix<I: ResolverIndex>(
10256    index: &I,
10257    src_prefix: &str,
10258    segments: &[String],
10259) -> Option<String> {
10260    let candidate = if segments.is_empty() {
10261        [src_prefix, "lib.rs"].join("/")
10262    } else {
10263        format!("{}/{}.rs", src_prefix, segments.join("/"))
10264    };
10265    if index.contains_file(&candidate) {
10266        return Some(candidate);
10267    }
10268    if !segments.is_empty() {
10269        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10270        if index.contains_file(&mod_candidate) {
10271            return Some(mod_candidate);
10272        }
10273    }
10274    None
10275}
10276
10277fn rust_src_prefix(rel_path: &str) -> String {
10278    rel_path
10279        .split_once("/src/")
10280        .map(|(prefix, _)| format!("{prefix}/src"))
10281        .unwrap_or_else(|| "src".to_string())
10282}
10283
10284fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10285    let after_src = rel_path
10286        .split_once("/src/")
10287        .map(|(_, rest)| rest)
10288        .or_else(|| rel_path.strip_prefix("src/"))
10289        .unwrap_or(rel_path);
10290    if matches!(after_src, "lib.rs" | "main.rs") {
10291        return Vec::new();
10292    }
10293    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10294        return prefix.split('/').map(|item| item.to_string()).collect();
10295    }
10296    after_src
10297        .strip_suffix(".rs")
10298        .unwrap_or(after_src)
10299        .split('/')
10300        .map(|item| item.to_string())
10301        .collect()
10302}
10303
10304fn resolve_local_target<I: ResolverIndex>(
10305    _index: &I,
10306    caller_file: &str,
10307    full_ref: &str,
10308    short_name: &str,
10309    caller_data: &FileCallData,
10310) -> Option<(String, String, String)> {
10311    if !callgraph::is_bare_callee(full_ref, short_name) {
10312        return None;
10313    }
10314    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10315        .ok()
10316        .map(|symbol| {
10317            (
10318                "resolved_local".to_string(),
10319                caller_file.to_string(),
10320                symbol,
10321            )
10322        })
10323}
10324
10325impl<'a> ProjectIndex<'a> {
10326    fn from_parts(
10327        project_root: &Path,
10328        files: HashMap<String, DbFileIndex>,
10329        caller_data: HashMap<String, &'a FileCallData>,
10330        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10331    ) -> Self {
10332        Self {
10333            project_root: project_root.to_path_buf(),
10334            files,
10335            caller_data,
10336            workspace_crate_prefixes,
10337        }
10338    }
10339
10340    fn from_db_and_callers(
10341        tx: &Transaction<'_>,
10342        project_root: &Path,
10343        caller_extracts: &'a HashMap<String, FileExtract>,
10344        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10345    ) -> Result<Self> {
10346        // Incremental refreshes get a fresh snapshot memo so a watcher rewrite can
10347        // never observe declarations retained by an earlier refresh generation.
10348        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
10349        let mut files = load_db_file_indexes(tx, project_root, &module_resolution_memo)?;
10350        let mut caller_data = HashMap::new();
10351        for (rel_path, extract) in caller_extracts {
10352            files.insert(
10353                rel_path.clone(),
10354                DbFileIndex::from_extract(project_root, extract),
10355            );
10356            caller_data.insert(rel_path.clone(), &extract.data);
10357        }
10358        Ok(Self::from_parts(
10359            project_root,
10360            files,
10361            caller_data,
10362            workspace_crate_prefixes,
10363        ))
10364    }
10365
10366    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10367        self.files.get(rel_path).and_then(|file| file.lang)
10368    }
10369
10370    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10371        self.files
10372            .get(caller_file)
10373            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10374    }
10375
10376    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10377        self.files
10378            .get(rel_path)
10379            .map(|file| file.reexports.as_slice())
10380            .unwrap_or(&[])
10381    }
10382
10383    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10384        self.files.get(rel_path).and_then(|file| {
10385            file.node_by_scoped
10386                .get(symbol)
10387                .cloned()
10388                .or_else(|| file.node_by_bare.get(symbol).cloned())
10389        })
10390    }
10391
10392    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10393        self.files
10394            .get(rel_path)
10395            .and_then(|file| file.node_kind_by_id.get(node_id))
10396            .is_some_and(|kind| matches!(kind.as_str(), "function" | "method"))
10397    }
10398}
10399
10400impl DbFileIndex {
10401    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
10402        let mut node_by_scoped = HashMap::new();
10403        let mut node_by_bare = HashMap::new();
10404        for node in &extract.nodes {
10405            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10406            node_by_bare
10407                .entry(node.name.clone())
10408                .or_insert(node.id.clone());
10409        }
10410        let node_kind_by_id = extract
10411            .nodes
10412            .iter()
10413            .map(|node| (node.id.clone(), node.kind.clone()))
10414            .collect();
10415        let mut export_aliases = HashMap::new();
10416        for raw_ref in &extract.raw_refs {
10417            if raw_ref.kind == "export_alias" {
10418                if let (Some(exported), Some(source_symbol)) =
10419                    (&raw_ref.local_name, &raw_ref.requested_name)
10420                {
10421                    export_aliases.insert(exported.clone(), source_symbol.clone());
10422                }
10423            }
10424        }
10425        let mut module_targets = HashMap::new();
10426        let mut declared_module_targets = HashMap::new();
10427        let mut reexports = Vec::new();
10428        for raw_ref in &extract.raw_refs {
10429            if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10430                continue;
10431            }
10432            let Some(module_path) = &raw_ref.module_path else {
10433                continue;
10434            };
10435            let target_file = module_target_from_dependencies(project_root, &raw_ref.dependencies);
10436            module_targets
10437                .entry(module_path.clone())
10438                .or_insert_with(|| target_file.clone());
10439            if raw_ref.kind == "module" {
10440                declared_module_targets
10441                    .entry(module_path.clone())
10442                    .or_insert_with(|| target_file.clone());
10443            }
10444            if raw_ref.kind == "reexport" {
10445                reexports.push(reexport_index_from_raw(raw_ref, target_file));
10446            }
10447        }
10448        Self {
10449            lang: Some(extract.lang),
10450            exports: extract.data.exported_symbols.iter().cloned().collect(),
10451            default_export: extract.data.default_export_symbol.clone(),
10452            export_aliases,
10453            node_by_scoped,
10454            node_by_bare,
10455            node_kind_by_id,
10456            module_targets,
10457            declared_module_targets,
10458            reexports,
10459        }
10460    }
10461}
10462
10463fn load_db_file_indexes(
10464    tx: &Transaction<'_>,
10465    project_root: &Path,
10466    module_resolution_memo: &callgraph::ModuleResolutionMemo,
10467) -> Result<HashMap<String, DbFileIndex>> {
10468    let mut files = HashMap::new();
10469    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
10470    let rows = stmt.query_map([], |row| {
10471        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10472    })?;
10473    for row in rows {
10474        let (rel_path, lang) = row?;
10475        files.insert(
10476            rel_path.clone(),
10477            DbFileIndex {
10478                lang: lang_from_label(&lang),
10479                exports: HashSet::new(),
10480                default_export: None,
10481                export_aliases: HashMap::new(),
10482                node_by_scoped: HashMap::new(),
10483                node_by_bare: HashMap::new(),
10484                node_kind_by_id: HashMap::new(),
10485                module_targets: HashMap::new(),
10486                declared_module_targets: HashMap::new(),
10487                reexports: Vec::new(),
10488            },
10489        );
10490    }
10491
10492    let mut node_stmt = tx.prepare(
10493        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
10494    )?;
10495    let nodes = node_stmt.query_map([], |row| {
10496        Ok((
10497            row.get::<_, String>(0)?,
10498            row.get::<_, String>(1)?,
10499            row.get::<_, String>(2)?,
10500            row.get::<_, String>(3)?,
10501            row.get::<_, String>(4)?,
10502            row.get::<_, i64>(5)? != 0,
10503            row.get::<_, i64>(6)? != 0,
10504        ))
10505    })?;
10506    for row in nodes {
10507        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
10508        let file = files
10509            .entry(file_path.clone())
10510            .or_insert_with(|| DbFileIndex {
10511                lang: None,
10512                exports: HashSet::new(),
10513                default_export: None,
10514                export_aliases: HashMap::new(),
10515                node_by_scoped: HashMap::new(),
10516                node_by_bare: HashMap::new(),
10517                node_kind_by_id: HashMap::new(),
10518                module_targets: HashMap::new(),
10519                declared_module_targets: HashMap::new(),
10520                reexports: Vec::new(),
10521            });
10522        if exported {
10523            file.exports.insert(name.clone());
10524            file.exports.insert(scoped_name.clone());
10525        }
10526        if is_default_export {
10527            file.default_export = Some(scoped_name.clone());
10528        }
10529        file.node_by_scoped.insert(scoped_name, id.clone());
10530        file.node_by_bare.entry(name).or_insert(id.clone());
10531        file.node_kind_by_id.insert(id, kind);
10532    }
10533    let file_keys: HashSet<String> = files.keys().cloned().collect();
10534    // Persisted caller extracts supply import targets. Only reexports from other
10535    // files need dependency reconstruction, and their caller dependencies are
10536    // loaded once instead of issuing repeated SQLite queries per reference.
10537    let dependencies_by_file = load_file_dependencies_index(tx)?;
10538    let mut ref_stmt = tx.prepare(
10539        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
10540             FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
10541    )?;
10542    let ref_rows = ref_stmt.query_map([], |row| {
10543        Ok((
10544            row.get::<_, String>(0)?,
10545            row.get::<_, String>(1)?,
10546            row.get::<_, String>(2)?,
10547            row.get::<_, Option<String>>(3)?,
10548            row.get::<_, Option<String>>(4)?,
10549            row.get::<_, i64>(5)? != 0,
10550            row.get::<_, Option<String>>(6)?,
10551            row.get::<_, Option<String>>(7)?,
10552        ))
10553    })?;
10554    for row in ref_rows {
10555        let (
10556            ref_id,
10557            caller_file,
10558            kind,
10559            module_path,
10560            full_ref,
10561            wildcard,
10562            local_name,
10563            requested_name,
10564        ) = row?;
10565        if kind == "export_alias" {
10566            if let (Some(exported), Some(source_symbol), Some(file)) =
10567                (local_name, requested_name, files.get_mut(&caller_file))
10568            {
10569                file.export_aliases.insert(exported, source_symbol);
10570            }
10571            continue;
10572        }
10573        let Some(module_path) = module_path else {
10574            continue;
10575        };
10576        let file_deps = dependencies_by_file
10577            .get(&caller_file)
10578            .cloned()
10579            .unwrap_or_default();
10580        let deps = stored_dependencies_for_module(
10581            project_root,
10582            &caller_file,
10583            &module_path,
10584            &file_deps,
10585            &file_keys,
10586        );
10587        let target_file = if kind == "module" {
10588            rust_declared_module_target(
10589                project_root,
10590                &caller_file,
10591                &module_path,
10592                module_resolution_memo,
10593            )
10594        } else {
10595            deps.iter()
10596                .find(|dep| file_keys.contains(*dep))
10597                .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))))
10598        };
10599        if let Some(file) = files.get_mut(&caller_file) {
10600            file.module_targets
10601                .entry(module_path.clone())
10602                .or_insert_with(|| target_file.clone());
10603            if kind == "module" {
10604                file.declared_module_targets
10605                    .entry(module_path.clone())
10606                    .or_insert_with(|| target_file.clone());
10607            }
10608            if kind == "reexport" {
10609                let raw = RawRef {
10610                    ref_id,
10611                    caller_node: None,
10612                    caller_symbol: None,
10613                    caller_file,
10614                    kind,
10615                    short_name: None,
10616                    full_ref,
10617                    module_path: Some(module_path),
10618                    import_kind: Some("reexport".to_string()),
10619                    local_name: None,
10620                    requested_name: None,
10621                    namespace_alias: None,
10622                    wildcard,
10623                    line: 0,
10624                    byte_start: 0,
10625                    byte_end: 0,
10626                    dependencies: deps,
10627                };
10628                file.reexports
10629                    .push(reexport_index_from_raw(&raw, target_file));
10630            }
10631        }
10632    }
10633
10634    Ok(files)
10635}
10636
10637fn stored_dependencies_for_module(
10638    project_root: &Path,
10639    caller_file: &str,
10640    module_path: &str,
10641    caller_dependencies: &BTreeSet<String>,
10642    indexed_files: &HashSet<String>,
10643) -> BTreeSet<String> {
10644    let caller_path = project_root.join(caller_file);
10645    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path);
10646    if module_path.starts_with('.') {
10647        let caller_dir = caller_path.parent().unwrap_or(project_root);
10648        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
10649            let normalized = if candidate.is_file() {
10650                canonicalize_path(&candidate)
10651            } else {
10652                candidate
10653            };
10654            candidates.insert(relative_path(project_root, &normalized));
10655        }
10656    }
10657    let exact = candidates
10658        .intersection(caller_dependencies)
10659        .filter(|dependency| indexed_files.contains(*dependency))
10660        .cloned()
10661        .collect::<BTreeSet<_>>();
10662    if !exact.is_empty() || module_path.starts_with('.') {
10663        return exact;
10664    }
10665
10666    let module_path = rust_module_path_without_alias_or_use_list(module_path)
10667        .trim_matches(|character| matches!(character, '\'' | '"'));
10668    let package_name = module_path
10669        .split('/')
10670        .next_back()
10671        .unwrap_or(module_path)
10672        .replace('_', "-");
10673    let matched = caller_dependencies
10674        .iter()
10675        .filter(|dependency| indexed_files.contains(*dependency))
10676        .filter(|dependency| {
10677            dependency.as_str() == module_path
10678                || dependency.ends_with(&format!("/{module_path}"))
10679                || Path::new(dependency).components().any(|component| {
10680                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
10681                })
10682        })
10683        .cloned()
10684        .collect::<BTreeSet<_>>();
10685    if matched.len() == 1 {
10686        matched
10687    } else {
10688        BTreeSet::new()
10689    }
10690}
10691
10692fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
10693    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
10694    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
10695    let rows = stmt.query_map([], |row| {
10696        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10697    })?;
10698    for row in rows {
10699        let (file_path, dependency) = row?;
10700        by_file.entry(file_path).or_default().insert(dependency);
10701    }
10702    Ok(by_file)
10703}
10704
10705struct ColdBuildInsertStatements<'stmt> {
10706    file: Statement<'stmt>,
10707    node: Statement<'stmt>,
10708    file_dependency: Statement<'stmt>,
10709    dispatch_hint: Statement<'stmt>,
10710    backend_state: Statement<'stmt>,
10711    reference: Statement<'stmt>,
10712    staging_ref_context: Statement<'stmt>,
10713    edge: Statement<'stmt>,
10714}
10715
10716impl<'stmt> ColdBuildInsertStatements<'stmt> {
10717    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
10718        Ok(Self {
10719            file: tx.prepare(
10720                "INSERT OR REPLACE INTO files(
10721                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10722                    is_public_api, surface_fingerprint, indexed_at
10723                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10724            )?,
10725            node: tx.prepare(
10726                "INSERT OR REPLACE INTO nodes(
10727                    id, file_path, name, scoped_name, kind, start_line, start_col,
10728                    end_line, end_col, range_ordinal, signature, exported,
10729                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
10730                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10731            )?,
10732            file_dependency: tx.prepare(
10733                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
10734            )?,
10735            dispatch_hint: tx.prepare(
10736                "INSERT OR REPLACE INTO dispatch_hints(
10737                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10738                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10739            )?,
10740            backend_state: tx.prepare(
10741                "INSERT OR REPLACE INTO backend_file_state(
10742                    backend, workspace_root, file_path, content_hash, status, updated_at
10743                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
10744            )?,
10745            reference: tx.prepare(
10746                "INSERT OR REPLACE INTO refs(
10747                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
10748                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
10749                    byte_start, byte_end, status, target_node, target_file, target_symbol,
10750                    provenance
10751                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
10752            )?,
10753            staging_ref_context: tx.prepare(
10754                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
10755            )?,
10756            edge: tx.prepare(
10757                "INSERT OR REPLACE INTO edges(
10758                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
10759                    kind, line, provenance
10760                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
10761            )?,
10762        })
10763    }
10764}
10765
10766fn insert_file_extract_prepared(
10767    statements: &mut ColdBuildInsertStatements<'_>,
10768    workspace_root: &str,
10769    extract: &FileExtract,
10770) -> Result<()> {
10771    statements.file.execute(params![
10772        extract.rel_path,
10773        hash_to_hex(extract.freshness.content_hash),
10774        system_time_to_ns(extract.freshness.mtime),
10775        extract.freshness.size as i64,
10776        lang_label(extract.lang),
10777        extract.surface_fingerprint,
10778        unix_seconds_now(),
10779    ])?;
10780    for node in &extract.nodes {
10781        statements.node.execute(params![
10782            node.id,
10783            node.file_path,
10784            node.name,
10785            node.scoped_name,
10786            node.kind,
10787            node.range.start_line as i64,
10788            node.range.start_col as i64,
10789            node.range.end_line as i64,
10790            node.range.end_col as i64,
10791            node.range_ordinal as i64,
10792            node.signature,
10793            bool_int(node.exported),
10794            bool_int(node.is_default_export),
10795            bool_int(node.is_type_like),
10796            bool_int(node.is_callgraph_entry_point),
10797            PROVENANCE_TREESITTER,
10798        ])?;
10799    }
10800
10801    let mut dependencies = BTreeSet::new();
10802    for raw_ref in &extract.raw_refs {
10803        dependencies.extend(raw_ref.dependencies.iter().cloned());
10804    }
10805    for dep_file in &dependencies {
10806        statements
10807            .file_dependency
10808            .execute(params![extract.rel_path, dep_file])?;
10809    }
10810
10811    for hint in &extract.dispatch_hints {
10812        statements.dispatch_hint.execute(params![
10813            hint.id,
10814            hint.method_name,
10815            hint.caller_node,
10816            hint.file,
10817            hint.line as i64,
10818            hint.byte_start as i64,
10819            hint.byte_end as i64,
10820            PROVENANCE_TREESITTER,
10821        ])?;
10822    }
10823    insert_backend_state_prepared(
10824        &mut statements.backend_state,
10825        workspace_root,
10826        &extract.rel_path,
10827        Some(&extract.freshness.content_hash),
10828        "fresh",
10829    )?;
10830    Ok(())
10831}
10832
10833fn insert_backend_state_prepared(
10834    stmt: &mut Statement<'_>,
10835    workspace_root: &str,
10836    rel_path: &str,
10837    content_hash: Option<&blake3::Hash>,
10838    status: &str,
10839) -> Result<()> {
10840    let hash = content_hash
10841        .map(|hash| hash_to_hex(*hash))
10842        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
10843    stmt.execute(params![
10844        BACKEND_TREESITTER,
10845        workspace_root,
10846        rel_path,
10847        hash,
10848        status,
10849        unix_seconds_now(),
10850    ])?;
10851    Ok(())
10852}
10853
10854fn insert_staged_ref_prepared(
10855    statements: &mut ColdBuildInsertStatements<'_>,
10856    raw: &RawRef,
10857) -> Result<()> {
10858    statements.reference.execute(params![
10859        raw.ref_id,
10860        raw.caller_node,
10861        raw.caller_file,
10862        raw.kind,
10863        raw.short_name,
10864        raw.full_ref,
10865        raw.module_path,
10866        raw.import_kind,
10867        raw.local_name,
10868        raw.requested_name,
10869        raw.namespace_alias,
10870        bool_int(raw.wildcard),
10871        raw.line as i64,
10872        raw.byte_start as i64,
10873        raw.byte_end as i64,
10874        "staged",
10875        Option::<String>::None,
10876        Option::<String>::None,
10877        Option::<String>::None,
10878        ref_provenance(raw),
10879    ])?;
10880    statements
10881        .staging_ref_context
10882        .execute(params![raw.ref_id, raw.caller_symbol])?;
10883    Ok(())
10884}
10885
10886fn insert_resolved_ref_prepared(
10887    statements: &mut ColdBuildInsertStatements<'_>,
10888    resolved: &ResolvedRef,
10889) -> Result<()> {
10890    let raw = &resolved.raw;
10891    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
10892    statements.reference.execute(params![
10893        raw.ref_id,
10894        raw.caller_node,
10895        raw.caller_file,
10896        raw.kind,
10897        raw.short_name,
10898        raw.full_ref,
10899        raw.module_path,
10900        raw.import_kind,
10901        raw.local_name,
10902        raw.requested_name,
10903        raw.namespace_alias,
10904        bool_int(raw.wildcard),
10905        raw.line as i64,
10906        raw.byte_start as i64,
10907        raw.byte_end as i64,
10908        resolved.status,
10909        resolved.target_node,
10910        resolved.target_file,
10911        resolved.target_symbol,
10912        ref_provenance(raw),
10913    ])?;
10914    if let Some(edge) = &resolved.edge {
10915        statements.edge.execute(params![
10916            edge.edge_id,
10917            raw.ref_id,
10918            edge.source_node,
10919            edge.target_node,
10920            edge.target_file,
10921            edge.target_symbol,
10922            edge.kind,
10923            edge.line as i64,
10924            ref_provenance(raw),
10925        ])?;
10926    }
10927    Ok(())
10928}
10929
10930#[cfg(test)]
10931fn insert_file_extract(
10932    tx: &Transaction<'_>,
10933    project_root: &Path,
10934    extract: &FileExtract,
10935) -> Result<()> {
10936    tx.execute(
10937        "INSERT OR REPLACE INTO files(
10938            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
10939            is_public_api, surface_fingerprint, indexed_at
10940        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
10941        params![
10942            extract.rel_path,
10943            hash_to_hex(extract.freshness.content_hash),
10944            system_time_to_ns(extract.freshness.mtime),
10945            extract.freshness.size as i64,
10946            lang_label(extract.lang),
10947            extract.surface_fingerprint,
10948            unix_seconds_now(),
10949        ],
10950    )?;
10951    for node in &extract.nodes {
10952        tx.execute(
10953            "INSERT OR REPLACE INTO nodes(
10954                id, file_path, name, scoped_name, kind, start_line, start_col,
10955                end_line, end_col, range_ordinal, signature, exported,
10956                is_default_export, is_type_like, is_callgraph_entry_point, provenance
10957            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
10958            params![
10959                node.id,
10960                node.file_path,
10961                node.name,
10962                node.scoped_name,
10963                node.kind,
10964                node.range.start_line as i64,
10965                node.range.start_col as i64,
10966                node.range.end_line as i64,
10967                node.range.end_col as i64,
10968                node.range_ordinal as i64,
10969                node.signature,
10970                bool_int(node.exported),
10971                bool_int(node.is_default_export),
10972                bool_int(node.is_type_like),
10973                bool_int(node.is_callgraph_entry_point),
10974                PROVENANCE_TREESITTER,
10975            ],
10976        )?;
10977    }
10978    let mut dependencies = BTreeSet::new();
10979    for raw_ref in &extract.raw_refs {
10980        dependencies.extend(raw_ref.dependencies.iter().cloned());
10981    }
10982    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
10983
10984    for hint in &extract.dispatch_hints {
10985        tx.execute(
10986            "INSERT OR REPLACE INTO dispatch_hints(
10987                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
10988            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
10989            params![
10990                hint.id,
10991                hint.method_name,
10992                hint.caller_node,
10993                hint.file,
10994                hint.line as i64,
10995                hint.byte_start as i64,
10996                hint.byte_end as i64,
10997                PROVENANCE_TREESITTER,
10998            ],
10999        )?;
11000    }
11001    mark_backend_state(
11002        tx,
11003        project_root,
11004        &extract.rel_path,
11005        Some(&extract.freshness.content_hash),
11006        "fresh",
11007    )?;
11008    Ok(())
11009}
11010
11011#[cfg(test)]
11012fn insert_file_dependencies(
11013    tx: &Transaction<'_>,
11014    file_path: &str,
11015    dependencies: &BTreeSet<String>,
11016) -> Result<()> {
11017    for dep_file in dependencies {
11018        tx.execute(
11019            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11020            params![file_path, dep_file],
11021        )?;
11022    }
11023    Ok(())
11024}
11025
11026fn ref_provenance(raw: &RawRef) -> &'static str {
11027    if raw.kind == "value_ref" {
11028        PROVENANCE_VALUE_REF
11029    } else {
11030        PROVENANCE_TREESITTER
11031    }
11032}
11033
11034#[cfg(test)]
11035fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
11036    let raw = &resolved.raw;
11037    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11038    tx.execute(
11039        "INSERT OR REPLACE INTO refs(
11040            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11041            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11042            byte_start, byte_end, status, target_node, target_file, target_symbol,
11043            provenance
11044        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11045        params![
11046            raw.ref_id,
11047            raw.caller_node,
11048            raw.caller_file,
11049            raw.kind,
11050            raw.short_name,
11051            raw.full_ref,
11052            raw.module_path,
11053            raw.import_kind,
11054            raw.local_name,
11055            raw.requested_name,
11056            raw.namespace_alias,
11057            bool_int(raw.wildcard),
11058            raw.line as i64,
11059            raw.byte_start as i64,
11060            raw.byte_end as i64,
11061            resolved.status,
11062            resolved.target_node,
11063            resolved.target_file,
11064            resolved.target_symbol,
11065            ref_provenance(raw),
11066        ],
11067    )?;
11068    if let Some(edge) = &resolved.edge {
11069        tx.execute(
11070            "INSERT OR REPLACE INTO edges(
11071                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11072                kind, line, provenance
11073            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11074            params![
11075                edge.edge_id,
11076                raw.ref_id,
11077                edge.source_node,
11078                edge.target_node,
11079                edge.target_file,
11080                edge.target_symbol,
11081                edge.kind,
11082                edge.line as i64,
11083                ref_provenance(raw),
11084            ],
11085        )?;
11086    }
11087    Ok(())
11088}
11089
11090fn insert_method_dispatch_edges(
11091    tx: &Transaction<'_>,
11092    project_root: &Path,
11093    caller_files: Option<&BTreeSet<String>>,
11094) -> Result<usize> {
11095    let references = load_name_match_refs(tx, caller_files)?;
11096    if references.is_empty() {
11097        return Ok(0);
11098    }
11099
11100    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
11101    let mut source_cache: DispatchSourceCache = HashMap::new();
11102    let mut inserted = 0usize;
11103    for reference in references {
11104        let key = (reference.method_name.clone(), reference.lang.clone());
11105        let candidates = match candidates_by_name.entry(key) {
11106            Entry::Occupied(entry) => entry.into_mut(),
11107            Entry::Vacant(entry) => {
11108                let candidates =
11109                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11110                entry.insert(candidates)
11111            }
11112        };
11113
11114        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11115            ReceiverTypeInference::Known(receiver_type) => {
11116                let Some(candidate) =
11117                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11118                else {
11119                    continue;
11120                };
11121                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11122                inserted += 1;
11123                continue;
11124            }
11125            ReceiverTypeInference::RustDirectSelfField {
11126                receiver_type,
11127                declaration_file,
11128                module_scope,
11129            } => {
11130                let Some(candidate) = select_rust_direct_self_field_candidate(
11131                    project_root,
11132                    &reference,
11133                    candidates.as_slice(),
11134                    &receiver_type,
11135                    &declaration_file,
11136                    &module_scope,
11137                    &mut source_cache,
11138                ) else {
11139                    continue;
11140                };
11141                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11142                inserted += 1;
11143                continue;
11144            }
11145            ReceiverTypeInference::KnownButUnresolved => continue,
11146            ReceiverTypeInference::Unknown => {}
11147        }
11148
11149        if method_name_match_denylisted(&reference.method_name) {
11150            continue;
11151        }
11152
11153        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11154            continue;
11155        };
11156        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11157        inserted += 1;
11158    }
11159    Ok(inserted)
11160}
11161
11162fn insert_method_dispatch_edges_chunked(
11163    tx: &Transaction<'_>,
11164    project_root: &Path,
11165    chunk_size: usize,
11166) -> Result<usize> {
11167    let total_files = query_count(
11168        tx,
11169        "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11170    )? as usize;
11171    let mut completed_files = 0usize;
11172    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11173    let mut inserted = 0usize;
11174    let mut after_file = String::new();
11175    loop {
11176        let caller_files = {
11177            let mut statement = tx.prepare(
11178                "SELECT DISTINCT caller_file
11179                 FROM refs
11180                 WHERE caller_file > ?1
11181                 ORDER BY caller_file
11182                 LIMIT ?2",
11183            )?;
11184            let rows = statement
11185                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11186                    row.get::<_, String>(0)
11187                })?;
11188            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11189        };
11190        let Some(last_file) = caller_files.last().cloned() else {
11191            break;
11192        };
11193        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11194        after_file = last_file;
11195        completed_files = completed_files
11196            .saturating_add(caller_files.len())
11197            .min(total_files);
11198        ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11199    }
11200    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11201    Ok(inserted)
11202}
11203
11204fn insert_method_dispatch_edge(
11205    tx: &Transaction<'_>,
11206    reference: &NameMatchRef,
11207    candidate: &NameMatchCandidate,
11208    provenance: &str,
11209) -> Result<()> {
11210    tx.execute(
11211        "INSERT OR REPLACE INTO edges(
11212            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11213            kind, line, provenance
11214        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11215        params![
11216            ref_id(&[&reference.ref_id, provenance, "edge"]),
11217            &reference.ref_id,
11218            &reference.caller_node,
11219            &candidate.node_id,
11220            &candidate.file_path,
11221            &candidate.scoped_name,
11222            reference.line as i64,
11223            provenance,
11224        ],
11225    )?;
11226    Ok(())
11227}
11228
11229fn delete_method_dispatch_edges_for_callers(
11230    tx: &Transaction<'_>,
11231    caller_files: &BTreeSet<String>,
11232) -> Result<()> {
11233    if caller_files.is_empty() {
11234        return Ok(());
11235    }
11236
11237    let mut stmt = tx.prepare(
11238        "DELETE FROM edges
11239         WHERE provenance IN (?1, ?2)
11240           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11241    )?;
11242    for caller_file in caller_files {
11243        stmt.execute(params![
11244            PROVENANCE_NAME_MATCH,
11245            PROVENANCE_TYPE_MATCH,
11246            caller_file
11247        ])?;
11248    }
11249    Ok(())
11250}
11251
11252fn load_name_match_refs(
11253    tx: &Transaction<'_>,
11254    caller_files: Option<&BTreeSet<String>>,
11255) -> Result<Vec<NameMatchRef>> {
11256    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11257                           n.signature, r.short_name, r.full_ref, r.line, f.lang
11258                    FROM refs r
11259                    JOIN files f ON f.path = r.caller_file
11260                    JOIN nodes n ON n.id = r.caller_node
11261                    WHERE r.kind = 'call'
11262                      AND r.status = 'unresolved'
11263                      AND r.caller_node IS NOT NULL
11264                      AND r.full_ref IS NOT NULL
11265                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11266                      AND NOT EXISTS (
11267                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11268                      )";
11269    let mut references = Vec::new();
11270
11271    if let Some(caller_files) = caller_files {
11272        if caller_files.is_empty() {
11273            return Ok(references);
11274        }
11275        let sql = format!(
11276            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11277        );
11278        let mut stmt = tx.prepare(&sql)?;
11279        for caller_file in caller_files {
11280            let rows = stmt.query_map(params![caller_file], |row| {
11281                Ok((
11282                    row.get::<_, String>(0)?,
11283                    row.get::<_, Option<String>>(1)?,
11284                    row.get::<_, String>(2)?,
11285                    row.get::<_, String>(3)?,
11286                    row.get::<_, Option<String>>(4)?,
11287                    row.get::<_, Option<String>>(5)?,
11288                    row.get::<_, Option<String>>(6)?,
11289                    row.get::<_, i64>(7)?,
11290                    row.get::<_, String>(8)?,
11291                ))
11292            })?;
11293            for row in rows {
11294                let (
11295                    ref_id,
11296                    caller_node,
11297                    caller_file,
11298                    caller_symbol,
11299                    caller_signature,
11300                    short_name,
11301                    full_ref,
11302                    line,
11303                    lang,
11304                ) = row?;
11305                if let Some(reference) = name_match_ref_from_parts(
11306                    ref_id,
11307                    caller_node,
11308                    caller_file,
11309                    caller_symbol,
11310                    caller_signature,
11311                    short_name,
11312                    full_ref,
11313                    line,
11314                    lang,
11315                ) {
11316                    references.push(reference);
11317                }
11318            }
11319        }
11320        return Ok(references);
11321    }
11322
11323    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11324    let mut stmt = tx.prepare(&sql)?;
11325    let rows = stmt.query_map([], |row| {
11326        Ok((
11327            row.get::<_, String>(0)?,
11328            row.get::<_, Option<String>>(1)?,
11329            row.get::<_, String>(2)?,
11330            row.get::<_, String>(3)?,
11331            row.get::<_, Option<String>>(4)?,
11332            row.get::<_, Option<String>>(5)?,
11333            row.get::<_, Option<String>>(6)?,
11334            row.get::<_, i64>(7)?,
11335            row.get::<_, String>(8)?,
11336        ))
11337    })?;
11338    for row in rows {
11339        let (
11340            ref_id,
11341            caller_node,
11342            caller_file,
11343            caller_symbol,
11344            caller_signature,
11345            short_name,
11346            full_ref,
11347            line,
11348            lang,
11349        ) = row?;
11350        if let Some(reference) = name_match_ref_from_parts(
11351            ref_id,
11352            caller_node,
11353            caller_file,
11354            caller_symbol,
11355            caller_signature,
11356            short_name,
11357            full_ref,
11358            line,
11359            lang,
11360        ) {
11361            references.push(reference);
11362        }
11363    }
11364    Ok(references)
11365}
11366
11367#[allow(clippy::too_many_arguments)]
11368fn name_match_ref_from_parts(
11369    ref_id: String,
11370    caller_node: Option<String>,
11371    caller_file: String,
11372    caller_symbol: String,
11373    caller_signature: Option<String>,
11374    short_name: Option<String>,
11375    full_ref: Option<String>,
11376    line: i64,
11377    lang: String,
11378) -> Option<NameMatchRef> {
11379    let caller_node = caller_node?;
11380    let full_ref = full_ref?;
11381    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11382    let method_name = if member.is_empty() {
11383        short_name.as_deref()?.to_string()
11384    } else {
11385        member
11386    };
11387    Some(NameMatchRef {
11388        ref_id,
11389        caller_node,
11390        caller_file,
11391        caller_symbol,
11392        caller_signature,
11393        receiver_expression,
11394        receiver,
11395        method_name,
11396        colon_dispatch,
11397        line: line.max(0) as u32,
11398        lang,
11399    })
11400}
11401
11402fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11403    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11404    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11405    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11406    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11407        .into_iter()
11408        .flatten()
11409        .max_by_key(|(index, _, _)| *index)?;
11410    if delimiter == 0 {
11411        return None;
11412    }
11413    let member_start = delimiter + delimiter_len;
11414    if member_start >= full_ref.len() {
11415        return None;
11416    }
11417    let receiver_expression = full_ref[..delimiter].trim();
11418    let receiver = last_name_segment(receiver_expression).trim();
11419    let member = &full_ref[member_start..];
11420    if receiver.is_empty() || member.is_empty() {
11421        return None;
11422    }
11423    Some((
11424        receiver_expression.to_string(),
11425        receiver.to_string(),
11426        member.to_string(),
11427        colon_dispatch,
11428    ))
11429}
11430
11431fn last_name_segment(value: &str) -> &str {
11432    value
11433        .rsplit(['.', ':', '/', '\\', '-', '>'])
11434        .find(|segment| !segment.is_empty())
11435        .unwrap_or(value)
11436}
11437
11438fn load_name_match_candidates(
11439    tx: &Transaction<'_>,
11440    method_name: &str,
11441    lang: &str,
11442) -> Result<Vec<NameMatchCandidate>> {
11443    let mut stmt = tx.prepare(
11444        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
11445         FROM nodes n JOIN files f ON f.path = n.file_path
11446         WHERE n.name = ?1
11447           AND f.lang = ?2
11448           AND n.kind IN ('method', 'function')
11449         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
11450    )?;
11451    let rows = stmt.query_map(params![method_name, lang], |row| {
11452        Ok(NameMatchCandidate {
11453            node_id: row.get(0)?,
11454            file_path: row.get(1)?,
11455            scoped_name: row.get(2)?,
11456            kind: row.get(3)?,
11457            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
11458        })
11459    })?;
11460    rows.collect::<std::result::Result<Vec<_>, _>>()
11461        .map_err(Into::into)
11462}
11463
11464struct ParsedDispatchSource {
11465    source: String,
11466    tree: tree_sitter::Tree,
11467}
11468
11469type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
11470
11471#[derive(Debug, Clone, PartialEq, Eq)]
11472enum ReceiverTypeInference {
11473    Unknown,
11474    Known(String),
11475    RustDirectSelfField {
11476        receiver_type: String,
11477        declaration_file: String,
11478        module_scope: Vec<(usize, usize)>,
11479    },
11480    KnownButUnresolved,
11481}
11482
11483#[cfg(test)]
11484fn infer_receiver_type(
11485    project_root: &Path,
11486    reference: &NameMatchRef,
11487    source_cache: &mut DispatchSourceCache,
11488) -> Option<String> {
11489    match infer_receiver_type_state(project_root, reference, source_cache) {
11490        ReceiverTypeInference::Known(receiver_type)
11491        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
11492        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
11493    }
11494}
11495
11496fn infer_receiver_type_state(
11497    project_root: &Path,
11498    reference: &NameMatchRef,
11499    source_cache: &mut DispatchSourceCache,
11500) -> ReceiverTypeInference {
11501    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
11502    match reference.lang.as_str() {
11503        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
11504        "java" => {
11505            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
11506                .map(known)
11507                .unwrap_or(ReceiverTypeInference::Unknown)
11508        }
11509        "kotlin" => {
11510            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
11511                .map(known)
11512                .unwrap_or(ReceiverTypeInference::Unknown)
11513        }
11514        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
11515            .map(known)
11516            .unwrap_or(ReceiverTypeInference::Unknown),
11517        _ => ReceiverTypeInference::Unknown,
11518    }
11519}
11520
11521fn parse_dispatch_source(
11522    project_root: &Path,
11523    caller_file: &str,
11524    lang: LangId,
11525) -> Option<ParsedDispatchSource> {
11526    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
11527    let grammar = crate::parser::grammar_for(lang);
11528    let mut parser = tree_sitter::Parser::new();
11529    parser.set_language(&grammar).ok()?;
11530    let tree = parser.parse(&source, None)?;
11531    Some(ParsedDispatchSource { source, tree })
11532}
11533
11534fn parsed_dispatch_source<'a>(
11535    project_root: &Path,
11536    reference: &NameMatchRef,
11537    lang: LangId,
11538    source_cache: &'a mut DispatchSourceCache,
11539) -> Option<&'a ParsedDispatchSource> {
11540    parsed_dispatch_source_for_file(
11541        project_root,
11542        &reference.caller_file,
11543        &reference.lang,
11544        lang,
11545        source_cache,
11546    )
11547}
11548
11549fn parsed_dispatch_source_for_file<'a>(
11550    project_root: &Path,
11551    file_path: &str,
11552    lang_label: &str,
11553    lang: LangId,
11554    source_cache: &'a mut DispatchSourceCache,
11555) -> Option<&'a ParsedDispatchSource> {
11556    let key = (file_path.to_string(), lang_label.to_string());
11557    source_cache
11558        .entry(key)
11559        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
11560        .as_ref()
11561}
11562
11563fn infer_java_like_receiver_type(
11564    project_root: &Path,
11565    reference: &NameMatchRef,
11566    lang: LangId,
11567    source_cache: &mut DispatchSourceCache,
11568) -> Option<String> {
11569    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11570        return None;
11571    }
11572
11573    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
11574    let root = parsed.tree.root_node();
11575    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
11576
11577    let callable_scope = type_node
11578        .and_then(|node| {
11579            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
11580        })
11581        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
11582
11583    if let Some(callable_scope) = callable_scope {
11584        if let Some(receiver_type) = infer_java_like_local_receiver_type(
11585            callable_scope,
11586            &parsed.source,
11587            &reference.receiver,
11588            reference.line.max(1),
11589            lang,
11590        ) {
11591            return Some(receiver_type);
11592        }
11593    }
11594
11595    type_node.and_then(|node| {
11596        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
11597    })
11598}
11599
11600fn infer_cpp_receiver_type(
11601    project_root: &Path,
11602    reference: &NameMatchRef,
11603    source_cache: &mut DispatchSourceCache,
11604) -> Option<String> {
11605    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
11606        return None;
11607    }
11608
11609    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
11610    let root = parsed.tree.root_node();
11611    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
11612    infer_cpp_receiver_type_from_scope(
11613        scope,
11614        &parsed.source,
11615        &reference.receiver,
11616        reference.line.max(1),
11617    )
11618}
11619
11620fn find_enclosing_java_like_type_node<'tree>(
11621    root: tree_sitter::Node<'tree>,
11622    source: &str,
11623    reference: &NameMatchRef,
11624    lang: LangId,
11625) -> Option<tree_sitter::Node<'tree>> {
11626    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
11627        .and_then(|name| simple_type_name(&name));
11628    let line = reference.line.max(1);
11629    let mut best = None;
11630    let mut stack = vec![root];
11631    while let Some(node) = stack.pop() {
11632        if !node_contains_line(node, line) {
11633            continue;
11634        }
11635        if is_java_like_type_kind(node.kind(), lang) {
11636            let name = declaration_name(node, source);
11637            if expected_type
11638                .as_deref()
11639                .is_none_or(|expected| name == Some(expected))
11640            {
11641                best = tighter_node(best, node);
11642            }
11643        }
11644        push_named_children(node, &mut stack);
11645    }
11646    best
11647}
11648
11649fn find_enclosing_java_like_callable_node<'tree>(
11650    root: tree_sitter::Node<'tree>,
11651    source: &str,
11652    reference: &NameMatchRef,
11653    lang: LangId,
11654) -> Option<tree_sitter::Node<'tree>> {
11655    let expected_name = reference.caller_symbol.rsplit("::").next();
11656    let line = reference.line.max(1);
11657    let mut best = None;
11658    let mut stack = vec![root];
11659    while let Some(node) = stack.pop() {
11660        if !node_contains_line(node, line) {
11661            continue;
11662        }
11663        if is_java_like_callable_kind(node.kind(), lang) {
11664            let name = declaration_name(node, source);
11665            if expected_name.is_none_or(|expected| name == Some(expected)) {
11666                best = tighter_node(best, node);
11667            }
11668        }
11669        push_named_children(node, &mut stack);
11670    }
11671    best
11672}
11673
11674fn find_enclosing_cpp_callable_node<'tree>(
11675    root: tree_sitter::Node<'tree>,
11676    _source: &str,
11677    reference: &NameMatchRef,
11678) -> Option<tree_sitter::Node<'tree>> {
11679    let line = reference.line.max(1);
11680    let mut best = None;
11681    let mut stack = vec![root];
11682    while let Some(node) = stack.pop() {
11683        if !node_contains_line(node, line) {
11684            continue;
11685        }
11686        if node.kind() == "function_definition" {
11687            best = tighter_node(best, node);
11688        }
11689        push_named_children(node, &mut stack);
11690    }
11691    best
11692}
11693
11694fn tighter_node<'tree>(
11695    current: Option<tree_sitter::Node<'tree>>,
11696    candidate: tree_sitter::Node<'tree>,
11697) -> Option<tree_sitter::Node<'tree>> {
11698    match current {
11699        Some(current)
11700            if current.start_byte() > candidate.start_byte()
11701                || (current.start_byte() == candidate.start_byte()
11702                    && current.end_byte() <= candidate.end_byte()) =>
11703        {
11704            Some(current)
11705        }
11706        _ => Some(candidate),
11707    }
11708}
11709
11710fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
11711    let start = node.start_position().row as u32 + 1;
11712    let end = node.end_position().row as u32 + 1;
11713    start <= line && line <= end
11714}
11715
11716fn push_named_children<'tree>(
11717    node: tree_sitter::Node<'tree>,
11718    stack: &mut Vec<tree_sitter::Node<'tree>>,
11719) {
11720    for index in 0..node.named_child_count() {
11721        if let Some(child) = node.named_child(index as u32) {
11722            stack.push(child);
11723        }
11724    }
11725}
11726
11727fn declaration_name<'source>(
11728    node: tree_sitter::Node<'_>,
11729    source: &'source str,
11730) -> Option<&'source str> {
11731    node.child_by_field_name("name")
11732        .map(|name| node_text(name, source))
11733        .or_else(|| {
11734            first_named_child_text(
11735                node,
11736                source,
11737                &["identifier", "type_identifier", "simple_identifier"],
11738            )
11739        })
11740}
11741
11742fn first_named_child_text<'source>(
11743    node: tree_sitter::Node<'_>,
11744    source: &'source str,
11745    kinds: &[&str],
11746) -> Option<&'source str> {
11747    for index in 0..node.named_child_count() {
11748        let child = node.named_child(index as u32)?;
11749        if kinds.contains(&child.kind()) {
11750            return Some(node_text(child, source));
11751        }
11752    }
11753    None
11754}
11755
11756fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
11757    &source[node.byte_range()]
11758}
11759
11760fn infer_java_like_field_receiver_type(
11761    type_node: tree_sitter::Node<'_>,
11762    source: &str,
11763    receiver: &str,
11764    lang: LangId,
11765) -> Option<String> {
11766    let mut stack = Vec::new();
11767    push_named_children(type_node, &mut stack);
11768    while let Some(node) = stack.pop() {
11769        if is_java_like_field_kind(node.kind(), lang) {
11770            if let Some(receiver_type) =
11771                extract_java_like_declared_type(node_text(node, source), receiver, lang)
11772            {
11773                return Some(receiver_type);
11774            }
11775        }
11776        if is_java_like_type_kind(node.kind(), lang)
11777            || is_java_like_callable_kind(node.kind(), lang)
11778        {
11779            continue;
11780        }
11781        push_named_children(node, &mut stack);
11782    }
11783    None
11784}
11785
11786fn infer_java_like_local_receiver_type(
11787    callable_node: tree_sitter::Node<'_>,
11788    source: &str,
11789    receiver: &str,
11790    call_line: u32,
11791    lang: LangId,
11792) -> Option<String> {
11793    let mut best: Option<(u32, String)> = None;
11794    let mut stack = Vec::new();
11795    push_named_children(callable_node, &mut stack);
11796    while let Some(node) = stack.pop() {
11797        let start_line = node.start_position().row as u32 + 1;
11798        if start_line > call_line {
11799            continue;
11800        }
11801        if is_java_like_local_kind(node.kind(), lang) {
11802            if let Some(receiver_type) =
11803                extract_java_like_declared_type(node_text(node, source), receiver, lang)
11804            {
11805                if best
11806                    .as_ref()
11807                    .is_none_or(|(best_line, _)| start_line >= *best_line)
11808                {
11809                    best = Some((start_line, receiver_type));
11810                }
11811            }
11812        }
11813        if is_java_like_type_kind(node.kind(), lang)
11814            || is_java_like_callable_kind(node.kind(), lang)
11815        {
11816            continue;
11817        }
11818        push_named_children(node, &mut stack);
11819    }
11820    best.map(|(_, receiver_type)| receiver_type)
11821}
11822
11823fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
11824    match lang {
11825        LangId::Java => matches!(
11826            kind,
11827            "class_declaration"
11828                | "interface_declaration"
11829                | "enum_declaration"
11830                | "record_declaration"
11831                | "annotation_type_declaration"
11832        ),
11833        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
11834        _ => false,
11835    }
11836}
11837
11838fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
11839    match lang {
11840        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
11841        LangId::Kotlin => kind == "function_declaration",
11842        _ => false,
11843    }
11844}
11845
11846fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
11847    match lang {
11848        LangId::Java => kind == "field_declaration",
11849        LangId::Kotlin => kind == "property_declaration",
11850        _ => false,
11851    }
11852}
11853
11854fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
11855    match lang {
11856        LangId::Java => kind == "local_variable_declaration",
11857        LangId::Kotlin => kind == "property_declaration",
11858        _ => false,
11859    }
11860}
11861
11862fn extract_java_like_declared_type(
11863    declaration: &str,
11864    receiver: &str,
11865    lang: LangId,
11866) -> Option<String> {
11867    match lang {
11868        LangId::Java => extract_java_declared_type(declaration, receiver),
11869        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
11870        _ => None,
11871    }
11872}
11873
11874fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11875    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11876    let after = declaration[receiver_start + receiver.len()..].trim_start();
11877    if after
11878        .chars()
11879        .next()
11880        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
11881    {
11882        return None;
11883    }
11884
11885    let before = declaration[..receiver_start].trim_end();
11886    if before.contains(',') {
11887        return None;
11888    }
11889    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
11890}
11891
11892fn strip_java_declaration_prefixes(mut value: &str) -> &str {
11893    loop {
11894        value = value.trim_start();
11895        if let Some(stripped) = strip_leading_java_annotation(value) {
11896            value = stripped;
11897            continue;
11898        }
11899        if let Some(stripped) = strip_leading_java_modifier(value) {
11900            value = stripped;
11901            continue;
11902        }
11903        return value.trim();
11904    }
11905}
11906
11907fn strip_leading_java_annotation(value: &str) -> Option<&str> {
11908    let value = value.trim_start();
11909    let mut chars = value.char_indices();
11910    let (_, first) = chars.next()?;
11911    if first != '@' {
11912        return None;
11913    }
11914    let mut end = first.len_utf8();
11915    for (index, ch) in chars {
11916        if !(is_code_ident_char(ch) || ch == '.') {
11917            end = index;
11918            break;
11919        }
11920        end = index + ch.len_utf8();
11921    }
11922    let rest = value[end..].trim_start();
11923    if let Some(stripped) = rest.strip_prefix('(') {
11924        let mut depth = 1usize;
11925        for (index, ch) in stripped.char_indices() {
11926            match ch {
11927                '(' => depth += 1,
11928                ')' => {
11929                    depth = depth.saturating_sub(1);
11930                    if depth == 0 {
11931                        return Some(stripped[index + ch.len_utf8()..].trim_start());
11932                    }
11933                }
11934                _ => {}
11935            }
11936        }
11937        return Some("");
11938    }
11939    Some(rest)
11940}
11941
11942fn strip_leading_java_modifier(value: &str) -> Option<&str> {
11943    const MODIFIERS: &[&str] = &[
11944        "public",
11945        "protected",
11946        "private",
11947        "abstract",
11948        "static",
11949        "final",
11950        "transient",
11951        "volatile",
11952        "synchronized",
11953        "native",
11954        "strictfp",
11955    ];
11956    MODIFIERS
11957        .iter()
11958        .find_map(|modifier| strip_leading_word(value, modifier))
11959}
11960
11961fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
11962    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
11963    let before = &declaration[..receiver_start];
11964    if find_identifier_occurrence(before, "val").is_none()
11965        && find_identifier_occurrence(before, "var").is_none()
11966    {
11967        return None;
11968    }
11969
11970    let after = declaration[receiver_start + receiver.len()..].trim_start();
11971    if let Some(type_text) = after.strip_prefix(':') {
11972        return normalize_receiver_type_name(read_type_prefix(type_text));
11973    }
11974    after
11975        .strip_prefix('=')
11976        .and_then(infer_kotlin_constructor_type)
11977}
11978
11979fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
11980    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
11981    if rest.trim_start().starts_with('(') {
11982        normalize_receiver_type_name(head)
11983    } else {
11984        None
11985    }
11986}
11987
11988fn read_type_prefix(value: &str) -> &str {
11989    let mut angle_depth = 0usize;
11990    for (index, ch) in value.char_indices() {
11991        match ch {
11992            '<' => angle_depth += 1,
11993            '>' => angle_depth = angle_depth.saturating_sub(1),
11994            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
11995                return value[..index].trim();
11996            }
11997            _ => {}
11998        }
11999    }
12000    value.trim()
12001}
12002
12003fn infer_cpp_receiver_type_from_scope(
12004    scope: tree_sitter::Node<'_>,
12005    source: &str,
12006    receiver: &str,
12007    call_line: u32,
12008) -> Option<String> {
12009    let lines = source.lines().collect::<Vec<_>>();
12010    if lines.is_empty() {
12011        return None;
12012    }
12013    let scope_start = scope.start_position().row as usize;
12014    let call_index = (call_line as usize)
12015        .saturating_sub(1)
12016        .min(lines.len().saturating_sub(1));
12017    for index in (scope_start..=call_index).rev() {
12018        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
12019            return Some(receiver_type);
12020        }
12021    }
12022    None
12023}
12024
12025fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
12026    for receiver_start in identifier_occurrences(line, receiver) {
12027        let after = line[receiver_start + receiver.len()..].trim_start();
12028        if after
12029            .chars()
12030            .next()
12031            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
12032        {
12033            continue;
12034        }
12035        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
12036        let normalized = normalize_cpp_type_name(type_text)?;
12037        if normalized == "auto" {
12038            if let Some(rhs) = after.strip_prefix('=') {
12039                return infer_cpp_auto_receiver_type(rhs);
12040            }
12041            continue;
12042        }
12043        return Some(normalized);
12044    }
12045    None
12046}
12047
12048fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
12049    let candidate = prefix
12050        .rsplit([';', '{', '}', '('])
12051        .next()
12052        .unwrap_or(prefix)
12053        .trim();
12054    if candidate.is_empty() || candidate.ends_with(',') {
12055        None
12056    } else {
12057        Some(candidate)
12058    }
12059}
12060
12061fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
12062    let without_templates = strip_angle_groups(type_text);
12063    let mut cleaned = String::with_capacity(without_templates.len());
12064    for token in without_templates.split_whitespace() {
12065        if matches!(
12066            token,
12067            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
12068        ) {
12069            continue;
12070        }
12071        if !cleaned.is_empty() {
12072            cleaned.push(' ');
12073        }
12074        cleaned.push_str(token);
12075    }
12076    let token = cleaned
12077        .split_whitespace()
12078        .last()
12079        .unwrap_or(cleaned.trim())
12080        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
12081        .trim_matches(['*', '&']);
12082    let simple = token.rsplit("::").next().unwrap_or(token).trim();
12083    if simple.is_empty() || cpp_non_type_token(simple) {
12084        None
12085    } else {
12086        Some(simple.to_string())
12087    }
12088}
12089
12090fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
12091    let rhs = rhs.trim_start();
12092    if let Some(after_new) = rhs.strip_prefix("new ") {
12093        return infer_cpp_constructor_type(after_new);
12094    }
12095    infer_cpp_make_template_type(rhs)
12096        .or_else(|| infer_cpp_constructor_type(rhs))
12097        .or_else(|| infer_cpp_factory_type(rhs))
12098}
12099
12100fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
12101    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12102    let normalized = normalize_cpp_type_name(head)?;
12103    if !normalized
12104        .chars()
12105        .next()
12106        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12107    {
12108        return None;
12109    }
12110    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12111        Some(normalized)
12112    } else {
12113        None
12114    }
12115}
12116
12117fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12118    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12119    if !rest.trim_start().starts_with('(') {
12120        return None;
12121    }
12122    let base = head.split('<').next().unwrap_or(head);
12123    let base_simple = base.rsplit("::").next().unwrap_or(base);
12124    if !matches!(base_simple, "make_unique" | "make_shared") {
12125        return None;
12126    }
12127    first_angle_arg(head).and_then(normalize_cpp_type_name)
12128}
12129
12130fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12131    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12132    if !rest.trim_start().starts_with('(') {
12133        return None;
12134    }
12135    let simple = head
12136        .split('<')
12137        .next()
12138        .unwrap_or(head)
12139        .rsplit("::")
12140        .next()
12141        .unwrap_or(head);
12142    for prefix in ["make", "create", "build"] {
12143        if let Some(suffix) = simple.strip_prefix(prefix) {
12144            if suffix
12145                .chars()
12146                .next()
12147                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12148            {
12149                return normalize_cpp_type_name(suffix);
12150            }
12151        }
12152    }
12153    None
12154}
12155
12156#[derive(Debug, Clone, Copy)]
12157enum JavaLikeInvocation {
12158    Kotlin,
12159    Cpp,
12160}
12161
12162fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12163    let value = value.trim_start();
12164    let mut end = 0usize;
12165    for (index, ch) in value.char_indices() {
12166        let allowed_separator = match flavor {
12167            JavaLikeInvocation::Kotlin => ch == '.',
12168            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12169        };
12170        if is_code_ident_char(ch) || allowed_separator {
12171            end = index + ch.len_utf8();
12172            continue;
12173        }
12174        break;
12175    }
12176    if end == 0 {
12177        return None;
12178    }
12179    let mut rest = &value[end..];
12180    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12181        let skipped = skip_balanced_angle(stripped)?;
12182        let rest_start = rest.len() - rest.trim_start().len();
12183        let angle_len = 1 + skipped;
12184        end += rest_start + angle_len;
12185        rest = &value[end..];
12186    }
12187    Some((value[..end].trim(), rest))
12188}
12189
12190fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12191    let mut depth = 1usize;
12192    for (index, ch) in value_after_open.char_indices() {
12193        match ch {
12194            '<' => depth += 1,
12195            '>' => {
12196                depth = depth.saturating_sub(1);
12197                if depth == 0 {
12198                    return Some(index + ch.len_utf8());
12199                }
12200            }
12201            _ => {}
12202        }
12203    }
12204    None
12205}
12206
12207fn first_angle_arg(value: &str) -> Option<&str> {
12208    let open = value.find('<')?;
12209    let inner_len = skip_balanced_angle(&value[open + 1..])?;
12210    let inner = &value[open + 1..open + inner_len];
12211    split_top_level_commas(inner).into_iter().next()
12212}
12213
12214fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12215    let without_generics = strip_angle_groups(type_text);
12216    let cleaned = without_generics
12217        .replace("[]", " ")
12218        .replace("...", " ")
12219        .replace(['?', '&', '*'], " ");
12220    let token = cleaned
12221        .split_whitespace()
12222        .last()
12223        .unwrap_or(cleaned.trim())
12224        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12225    let token = token.rsplit("::").next().unwrap_or(token);
12226    let simple = token.rsplit('.').next().unwrap_or(token).trim();
12227    if simple.is_empty()
12228        || java_like_primitive_type(simple)
12229        || !simple
12230            .chars()
12231            .next()
12232            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12233    {
12234        None
12235    } else {
12236        Some(simple.to_string())
12237    }
12238}
12239
12240fn simple_type_name(scoped_name: &str) -> Option<String> {
12241    scoped_name
12242        .rsplit("::")
12243        .find(|segment| !segment.is_empty())
12244        .and_then(normalize_receiver_type_name)
12245}
12246
12247fn strip_angle_groups(value: &str) -> String {
12248    let mut output = String::with_capacity(value.len());
12249    let mut depth = 0usize;
12250    for ch in value.chars() {
12251        match ch {
12252            '<' => {
12253                if depth == 0 {
12254                    output.push(' ');
12255                }
12256                depth += 1;
12257            }
12258            '>' => depth = depth.saturating_sub(1),
12259            _ if depth == 0 => output.push(ch),
12260            _ => {}
12261        }
12262    }
12263    output
12264}
12265
12266fn java_like_primitive_type(value: &str) -> bool {
12267    matches!(
12268        value,
12269        "boolean"
12270            | "byte"
12271            | "char"
12272            | "double"
12273            | "float"
12274            | "int"
12275            | "long"
12276            | "short"
12277            | "void"
12278            | "Boolean"
12279            | "Byte"
12280            | "Char"
12281            | "Double"
12282            | "Float"
12283            | "Int"
12284            | "Long"
12285            | "Short"
12286            | "Unit"
12287    )
12288}
12289
12290fn cpp_non_type_token(value: &str) -> bool {
12291    matches!(
12292        value,
12293        "return"
12294            | "if"
12295            | "else"
12296            | "for"
12297            | "while"
12298            | "do"
12299            | "switch"
12300            | "case"
12301            | "default"
12302            | "break"
12303            | "continue"
12304            | "goto"
12305            | "throw"
12306            | "new"
12307            | "delete"
12308            | "co_await"
12309            | "co_yield"
12310            | "co_return"
12311            | "static_cast"
12312            | "const_cast"
12313            | "dynamic_cast"
12314            | "reinterpret_cast"
12315            | "sizeof"
12316            | "alignof"
12317            | "typeid"
12318            | "and"
12319            | "or"
12320            | "not"
12321            | "xor"
12322    )
12323}
12324
12325fn receiver_is_bare_identifier(value: &str) -> bool {
12326    let mut chars = value.chars();
12327    let Some(first) = chars.next() else {
12328        return false;
12329    };
12330    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12331}
12332
12333fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12334    identifier_occurrences(value, needle).into_iter().next()
12335}
12336
12337fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12338    value
12339        .match_indices(needle)
12340        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12341        .collect()
12342}
12343
12344fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12345    let before = value[..start].chars().next_back();
12346    let after = value[start + len..].chars().next();
12347    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12348}
12349
12350fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12351    let stripped = value.strip_prefix(word)?;
12352    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12353        Some(stripped.trim_start())
12354    } else {
12355        None
12356    }
12357}
12358
12359fn is_code_ident_char(ch: char) -> bool {
12360    ch == '_' || ch.is_ascii_alphanumeric()
12361}
12362
12363fn infer_rust_receiver_type(
12364    project_root: &Path,
12365    reference: &NameMatchRef,
12366    source_cache: &mut DispatchSourceCache,
12367) -> ReceiverTypeInference {
12368    if matches!(reference.receiver.as_str(), "self" | "Self") {
12369        return enclosing_type_from_scoped_name(&reference.caller_symbol)
12370            .map(ReceiverTypeInference::Known)
12371            .unwrap_or(ReceiverTypeInference::Unknown);
12372    }
12373
12374    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12375        return ReceiverTypeInference::Known(reference.receiver.clone());
12376    }
12377
12378    if let Some(receiver_type) = reference
12379        .caller_signature
12380        .as_deref()
12381        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12382    {
12383        return ReceiverTypeInference::Known(receiver_type);
12384    }
12385
12386    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12387}
12388
12389fn infer_rust_direct_self_field_receiver_type(
12390    project_root: &Path,
12391    reference: &NameMatchRef,
12392    source_cache: &mut DispatchSourceCache,
12393) -> ReceiverTypeInference {
12394    if reference.colon_dispatch {
12395        return ReceiverTypeInference::Unknown;
12396    }
12397    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12398        return ReceiverTypeInference::Unknown;
12399    };
12400    if field_name != reference.receiver {
12401        return ReceiverTypeInference::Unknown;
12402    }
12403
12404    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12405        return ReceiverTypeInference::Unknown;
12406    };
12407    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12408        return ReceiverTypeInference::KnownButUnresolved;
12409    };
12410    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12411    else {
12412        return ReceiverTypeInference::Unknown;
12413    };
12414    let Some(impl_node) =
12415        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12416    else {
12417        return ReceiverTypeInference::Unknown;
12418    };
12419    if impl_node.child_by_field_name("trait").is_some()
12420        || impl_node.child_by_field_name("type_parameters").is_some()
12421    {
12422        return ReceiverTypeInference::KnownButUnresolved;
12423    }
12424    let Some(impl_target) = impl_node.child_by_field_name("type") else {
12425        return ReceiverTypeInference::KnownButUnresolved;
12426    };
12427    if impl_target.kind() != "type_identifier"
12428        || node_text(impl_target, &parsed.source) != impl_type
12429    {
12430        return ReceiverTypeInference::KnownButUnresolved;
12431    }
12432
12433    let module_scope = rust_module_scope(impl_node);
12434    let Some(struct_node) = find_unique_rust_struct(
12435        parsed.tree.root_node(),
12436        &parsed.source,
12437        struct_name,
12438        &module_scope,
12439    ) else {
12440        return ReceiverTypeInference::KnownButUnresolved;
12441    };
12442    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
12443    else {
12444        return ReceiverTypeInference::KnownButUnresolved;
12445    };
12446    if field_type.kind() != "type_identifier" {
12447        return ReceiverTypeInference::KnownButUnresolved;
12448    }
12449    let field_type_name = node_text(field_type, &parsed.source);
12450    if find_unique_rust_struct(
12451        parsed.tree.root_node(),
12452        &parsed.source,
12453        field_type_name,
12454        &module_scope,
12455    )
12456    .is_none()
12457    {
12458        return ReceiverTypeInference::KnownButUnresolved;
12459    }
12460
12461    ReceiverTypeInference::RustDirectSelfField {
12462        receiver_type: field_type_name.to_string(),
12463        declaration_file: reference.caller_file.clone(),
12464        module_scope,
12465    }
12466}
12467
12468fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
12469    let (base, field) = receiver_expression.split_once('.')?;
12470    let base = base.trim();
12471    let field = field.trim();
12472    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
12473}
12474
12475fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
12476    let name = value.rsplit("::").next()?.trim();
12477    (!name.is_empty()
12478        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
12479        && name.chars().all(is_rust_ident_char))
12480    .then_some(name)
12481}
12482
12483fn find_enclosing_rust_impl_node<'tree>(
12484    root: tree_sitter::Node<'tree>,
12485    line: u32,
12486) -> Option<tree_sitter::Node<'tree>> {
12487    let mut best = None;
12488    let mut stack = vec![root];
12489    while let Some(node) = stack.pop() {
12490        if !node_contains_line(node, line) {
12491            continue;
12492        }
12493        if node.kind() == "impl_item" {
12494            best = tighter_node(best, node);
12495        }
12496        push_named_children(node, &mut stack);
12497    }
12498    best
12499}
12500
12501fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
12502    let mut scope = Vec::new();
12503    let mut current = node.parent();
12504    while let Some(parent) = current {
12505        if parent.kind() == "mod_item" {
12506            scope.push((parent.start_byte(), parent.end_byte()));
12507        }
12508        current = parent.parent();
12509    }
12510    scope.reverse();
12511    scope
12512}
12513
12514fn find_unique_rust_struct<'tree>(
12515    root: tree_sitter::Node<'tree>,
12516    source: &str,
12517    expected_name: &str,
12518    module_scope: &[(usize, usize)],
12519) -> Option<tree_sitter::Node<'tree>> {
12520    let mut found = None;
12521    let mut stack = vec![root];
12522    while let Some(node) = stack.pop() {
12523        if node.kind() == "struct_item"
12524            && rust_module_scope(node) == module_scope
12525            && node.child_by_field_name("type_parameters").is_none()
12526            && declaration_name(node, source) == Some(expected_name)
12527        {
12528            if found.is_some() {
12529                return None;
12530            }
12531            found = Some(node);
12532        }
12533        push_named_children(node, &mut stack);
12534    }
12535    found
12536}
12537
12538fn rust_struct_field_type_node<'tree>(
12539    struct_node: tree_sitter::Node<'tree>,
12540    source: &str,
12541    field_name: &str,
12542) -> Option<tree_sitter::Node<'tree>> {
12543    let fields = struct_node.child_by_field_name("body")?;
12544    if fields.kind() != "field_declaration_list" {
12545        return None;
12546    }
12547    for index in 0..fields.named_child_count() {
12548        let field = fields.named_child(index as u32)?;
12549        if field.kind() != "field_declaration"
12550            || declaration_name(field, source) != Some(field_name)
12551        {
12552            continue;
12553        }
12554        return field.child_by_field_name("type");
12555    }
12556    None
12557}
12558
12559fn rust_receiver_looks_type_like(receiver: &str) -> bool {
12560    receiver
12561        .chars()
12562        .next()
12563        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
12564}
12565
12566fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
12567    scoped_name
12568        .rsplit_once("::")
12569        .map(|(enclosing, _)| enclosing)
12570        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
12571        .map(ToString::to_string)
12572}
12573
12574fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
12575    let params = signature_parameter_text(signature)?;
12576    for param in split_top_level_commas(params) {
12577        let Some((pattern, type_text)) = param.split_once(':') else {
12578            continue;
12579        };
12580        let Some(name) = rust_parameter_name(pattern) else {
12581            continue;
12582        };
12583        if name == receiver {
12584            return normalize_rust_receiver_type(type_text);
12585        }
12586    }
12587    None
12588}
12589
12590fn signature_parameter_text(signature: &str) -> Option<&str> {
12591    let open = signature.find('(')?;
12592    let mut depth = 0usize;
12593    for (offset, ch) in signature[open..].char_indices() {
12594        match ch {
12595            '(' => depth += 1,
12596            ')' => {
12597                depth = depth.saturating_sub(1);
12598                if depth == 0 {
12599                    return Some(&signature[open + 1..open + offset]);
12600                }
12601            }
12602            _ => {}
12603        }
12604    }
12605    None
12606}
12607
12608fn split_top_level_commas(value: &str) -> Vec<&str> {
12609    let mut parts = Vec::new();
12610    let mut start = 0usize;
12611    let mut angle_depth = 0usize;
12612    let mut paren_depth = 0usize;
12613    let mut bracket_depth = 0usize;
12614    for (index, ch) in value.char_indices() {
12615        match ch {
12616            '<' => angle_depth += 1,
12617            '>' => angle_depth = angle_depth.saturating_sub(1),
12618            '(' => paren_depth += 1,
12619            ')' => paren_depth = paren_depth.saturating_sub(1),
12620            '[' => bracket_depth += 1,
12621            ']' => bracket_depth = bracket_depth.saturating_sub(1),
12622            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
12623                let part = value[start..index].trim();
12624                if !part.is_empty() {
12625                    parts.push(part);
12626                }
12627                start = index + ch.len_utf8();
12628            }
12629            _ => {}
12630        }
12631    }
12632    let part = value[start..].trim();
12633    if !part.is_empty() {
12634        parts.push(part);
12635    }
12636    parts
12637}
12638
12639fn rust_parameter_name(pattern: &str) -> Option<&str> {
12640    let mut pattern = pattern.trim();
12641    if let Some(stripped) = pattern.strip_prefix("mut ") {
12642        pattern = stripped.trim_start();
12643    }
12644    pattern
12645        .rsplit(|ch: char| !is_rust_ident_char(ch))
12646        .find(|part| !part.is_empty())
12647}
12648
12649fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
12650    let mut ty = strip_leading_rust_type_modifiers(type_text);
12651    let owned_inner;
12652    if let Some(inner) = single_outer_generic_arg(ty) {
12653        owned_inner = inner.trim().to_string();
12654        ty = strip_leading_rust_type_modifiers(&owned_inner);
12655    }
12656    rust_base_type_ident(ty)
12657}
12658
12659fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
12660    loop {
12661        ty = ty.trim_start();
12662        if let Some(stripped) = ty.strip_prefix('&') {
12663            ty = stripped.trim_start();
12664            if let Some(stripped) = strip_leading_lifetime(ty) {
12665                ty = stripped.trim_start();
12666            }
12667            if let Some(stripped) = ty.strip_prefix("mut ") {
12668                ty = stripped.trim_start();
12669            }
12670            continue;
12671        }
12672        if let Some(stripped) = ty.strip_prefix("mut ") {
12673            ty = stripped.trim_start();
12674            continue;
12675        }
12676        if let Some(stripped) = ty.strip_prefix("dyn ") {
12677            ty = stripped.trim_start();
12678            continue;
12679        }
12680        if let Some(stripped) = ty.strip_prefix("impl ") {
12681            ty = stripped.trim_start();
12682            continue;
12683        }
12684        break ty.trim();
12685    }
12686}
12687
12688fn strip_leading_lifetime(value: &str) -> Option<&str> {
12689    let mut chars = value.char_indices();
12690    let (_, first) = chars.next()?;
12691    if first != '\'' {
12692        return None;
12693    }
12694    for (index, ch) in chars {
12695        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
12696            return Some(&value[index..]);
12697        }
12698    }
12699    Some("")
12700}
12701
12702fn single_outer_generic_arg(ty: &str) -> Option<&str> {
12703    let ty = ty.trim();
12704    let open = ty.find('<')?;
12705    let mut depth = 0usize;
12706    let mut close = None;
12707    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
12708        match ch {
12709            '<' => depth += 1,
12710            '>' => {
12711                depth = depth.saturating_sub(1);
12712                if depth == 0 {
12713                    close = Some(index);
12714                    break;
12715                }
12716            }
12717            _ => {}
12718        }
12719    }
12720    let close = close?;
12721    if !ty[close + 1..].trim().is_empty() {
12722        return None;
12723    }
12724    let inner = &ty[open + 1..close];
12725    let args = split_top_level_commas(inner);
12726    match args.as_slice() {
12727        [arg] => Some(*arg),
12728        _ => None,
12729    }
12730}
12731
12732fn rust_base_type_ident(ty: &str) -> Option<String> {
12733    let ty = ty.trim();
12734    let head = ty
12735        .split([' ', '+', '='])
12736        .find(|part| !part.is_empty())
12737        .unwrap_or(ty);
12738    let head = head.split('<').next().unwrap_or(head).trim();
12739    let ident = head
12740        .rsplit("::")
12741        .next()
12742        .unwrap_or(head)
12743        .trim_matches(|ch: char| !is_rust_ident_char(ch));
12744    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
12745        None
12746    } else {
12747        Some(ident.to_string())
12748    }
12749}
12750
12751fn is_rust_ident_char(ch: char) -> bool {
12752    ch == '_' || ch.is_ascii_alphanumeric()
12753}
12754
12755fn select_rust_direct_self_field_candidate(
12756    project_root: &Path,
12757    reference: &NameMatchRef,
12758    candidates: &[NameMatchCandidate],
12759    receiver_type: &str,
12760    declaration_file: &str,
12761    declaration_scope: &[(usize, usize)],
12762    source_cache: &mut DispatchSourceCache,
12763) -> Option<NameMatchCandidate> {
12764    let eligible = candidates
12765        .iter()
12766        .filter(|candidate| candidate.node_id != reference.caller_node)
12767        .filter(|candidate| {
12768            type_candidate_matches(candidate, receiver_type, &reference.method_name)
12769        })
12770        .filter(|candidate| {
12771            rust_direct_self_field_candidate_matches_scope(
12772                project_root,
12773                candidate,
12774                receiver_type,
12775                declaration_file,
12776                declaration_scope,
12777                source_cache,
12778            )
12779        })
12780        .collect::<Vec<_>>();
12781    match eligible.as_slice() {
12782        [candidate] => Some((**candidate).clone()),
12783        _ => None,
12784    }
12785}
12786
12787fn rust_direct_self_field_candidate_matches_scope(
12788    project_root: &Path,
12789    candidate: &NameMatchCandidate,
12790    receiver_type: &str,
12791    declaration_file: &str,
12792    declaration_scope: &[(usize, usize)],
12793    source_cache: &mut DispatchSourceCache,
12794) -> bool {
12795    if candidate.file_path != declaration_file {
12796        return false;
12797    }
12798    let Some(parsed) = parsed_dispatch_source_for_file(
12799        project_root,
12800        &candidate.file_path,
12801        "rust",
12802        LangId::Rust,
12803        source_cache,
12804    ) else {
12805        return false;
12806    };
12807    let Some(impl_node) =
12808        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
12809    else {
12810        return false;
12811    };
12812    if impl_node.child_by_field_name("trait").is_some()
12813        || impl_node.child_by_field_name("type_parameters").is_some()
12814    {
12815        return false;
12816    }
12817    let Some(impl_target) = impl_node.child_by_field_name("type") else {
12818        return false;
12819    };
12820    impl_target.kind() == "type_identifier"
12821        && node_text(impl_target, &parsed.source) == receiver_type
12822        && rust_module_scope(impl_node) == declaration_scope
12823}
12824
12825fn select_type_match_candidate(
12826    reference: &NameMatchRef,
12827    candidates: &[NameMatchCandidate],
12828    receiver_type: &str,
12829) -> Option<NameMatchCandidate> {
12830    let candidates = candidates
12831        .iter()
12832        .filter(|candidate| candidate.node_id != reference.caller_node)
12833        .filter(|candidate| {
12834            type_candidate_matches(candidate, receiver_type, &reference.method_name)
12835        })
12836        .collect::<Vec<_>>();
12837    match candidates.as_slice() {
12838        [candidate] => Some((**candidate).clone()),
12839        _ => None,
12840    }
12841}
12842
12843fn type_candidate_matches(
12844    candidate: &NameMatchCandidate,
12845    receiver_type: &str,
12846    method_name: &str,
12847) -> bool {
12848    let normalized_type = receiver_type.replace('.', "::");
12849    let suffix = format!("{normalized_type}::{method_name}");
12850    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
12851}
12852
12853fn select_name_match_candidate(
12854    reference: &NameMatchRef,
12855    candidates: &[NameMatchCandidate],
12856) -> Option<NameMatchCandidate> {
12857    let candidates = candidates
12858        .iter()
12859        .filter(|candidate| candidate.node_id != reference.caller_node)
12860        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
12861        .collect::<Vec<_>>();
12862    match candidates.as_slice() {
12863        [] => None,
12864        [candidate] => Some((**candidate).clone()),
12865        _ => select_scored_name_match_candidate(reference, &candidates),
12866    }
12867}
12868
12869fn candidate_allowed_for_reference(
12870    reference: &NameMatchRef,
12871    candidate: &NameMatchCandidate,
12872) -> bool {
12873    if !reference.colon_dispatch {
12874        return true;
12875    }
12876
12877    candidate.kind == "method"
12878        && candidate
12879            .scoped_name
12880            .split("::")
12881            .any(|segment| segment == reference.receiver)
12882}
12883
12884fn select_scored_name_match_candidate(
12885    reference: &NameMatchRef,
12886    candidates: &[&NameMatchCandidate],
12887) -> Option<NameMatchCandidate> {
12888    let receiver_words = split_camel_case(&reference.receiver);
12889    if receiver_words.is_empty() {
12890        return None;
12891    }
12892
12893    let mut best: Option<(&NameMatchCandidate, f64)> = None;
12894    let mut tied_best = false;
12895    for candidate in candidates {
12896        let candidate_words = split_camel_case(&candidate.scoped_name);
12897        let overlap = receiver_words
12898            .iter()
12899            .filter(|receiver_word| {
12900                candidate_words
12901                    .iter()
12902                    .any(|candidate_word| candidate_word == *receiver_word)
12903            })
12904            .count() as f64;
12905        let score =
12906            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
12907        match best {
12908            None => {
12909                best = Some((*candidate, score));
12910                tied_best = false;
12911            }
12912            Some((_, best_score)) if score > best_score => {
12913                best = Some((*candidate, score));
12914                tied_best = false;
12915            }
12916            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
12917                tied_best = true;
12918            }
12919            _ => {}
12920        }
12921    }
12922
12923    let (candidate, score) = best?;
12924    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
12925        Some(candidate.clone())
12926    } else {
12927        None
12928    }
12929}
12930
12931fn method_name_match_denylisted(method_name: &str) -> bool {
12932    matches!(
12933        method_name,
12934        "and_then"
12935            | "as_bytes"
12936            | "as_deref"
12937            | "as_mut"
12938            | "as_ref"
12939            | "as_str"
12940            | "borrow"
12941            | "borrow_mut"
12942            | "clear"
12943            | "clone"
12944            | "collect"
12945            | "contains"
12946            | "contains_key"
12947            | "count"
12948            | "dedup"
12949            | "default"
12950            | "drain"
12951            | "ends_with"
12952            | "entry"
12953            | "err"
12954            | "expect"
12955            | "extend"
12956            | "filter"
12957            | "filter_map"
12958            | "find"
12959            | "from"
12960            | "get"
12961            | "get_mut"
12962            | "insert"
12963            | "into"
12964            | "into_iter"
12965            | "is_empty"
12966            | "is_err"
12967            | "is_none"
12968            | "is_ok"
12969            | "is_some"
12970            | "iter"
12971            | "iter_mut"
12972            | "join"
12973            | "len"
12974            | "lock"
12975            | "map"
12976            | "map_err"
12977            | "max"
12978            | "min"
12979            | "new"
12980            | "next"
12981            | "ok"
12982            | "or_default"
12983            | "or_else"
12984            | "or_insert"
12985            | "or_insert_with"
12986            | "parse"
12987            | "pop"
12988            | "position"
12989            | "push"
12990            | "read"
12991            | "recv"
12992            | "remove"
12993            | "replace"
12994            | "retain"
12995            | "send"
12996            | "sort"
12997            | "sort_by"
12998            | "split"
12999            | "starts_with"
13000            | "sum"
13001            | "take"
13002            | "to_owned"
13003            | "to_string"
13004            | "trim"
13005            | "try_from"
13006            | "try_into"
13007            | "unwrap"
13008            | "unwrap_or"
13009            | "unwrap_or_default"
13010            | "unwrap_or_else"
13011            | "with_capacity"
13012            | "write"
13013    )
13014}
13015
13016fn split_camel_case(value: &str) -> Vec<String> {
13017    let chars = value.chars().collect::<Vec<_>>();
13018    let mut normalized = String::with_capacity(value.len() + 8);
13019    for (index, ch) in chars.iter().enumerate() {
13020        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
13021        let next = chars.get(index + 1);
13022        let is_separator = ch.is_whitespace()
13023            || matches!(
13024                ch,
13025                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
13026            );
13027        if is_separator {
13028            normalized.push(' ');
13029            continue;
13030        }
13031        let camel_boundary = previous.is_some_and(|prev| {
13032            (prev.is_lowercase() && ch.is_uppercase())
13033                || (prev.is_ascii_digit() && ch.is_alphabetic())
13034                || (prev.is_uppercase()
13035                    && ch.is_uppercase()
13036                    && next.is_some_and(|next| next.is_lowercase()))
13037        });
13038        if camel_boundary {
13039            normalized.push(' ');
13040        }
13041        normalized.push(*ch);
13042    }
13043
13044    normalized
13045        .split_whitespace()
13046        .filter(|word| word.len() > 1)
13047        .map(|word| word.to_ascii_lowercase())
13048        .collect()
13049}
13050
13051fn compute_path_proximity(left: &str, right: &str) -> f64 {
13052    let left_dirs = left
13053        .rsplit_once('/')
13054        .map(|(dir, _)| dir)
13055        .unwrap_or_default()
13056        .split('/')
13057        .filter(|part| !part.is_empty());
13058    let right_dirs = right
13059        .rsplit_once('/')
13060        .map(|(dir, _)| dir)
13061        .unwrap_or_default()
13062        .split('/')
13063        .filter(|part| !part.is_empty());
13064
13065    let shared = left_dirs
13066        .zip(right_dirs)
13067        .take_while(|(left, right)| left == right)
13068        .count();
13069    ((shared as f64) * 0.05).min(0.5)
13070}
13071
13072fn mark_backend_state(
13073    tx: &Transaction<'_>,
13074    project_root: &Path,
13075    rel_path: &str,
13076    content_hash: Option<&blake3::Hash>,
13077    status: &str,
13078) -> Result<()> {
13079    clear_backend_state_for_file(tx, project_root, rel_path)?;
13080    let hash = content_hash
13081        .map(|hash| hash_to_hex(*hash))
13082        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
13083    tx.execute(
13084        "INSERT OR REPLACE INTO backend_file_state(
13085            backend, workspace_root, file_path, content_hash, status, updated_at
13086        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
13087        params![
13088            BACKEND_TREESITTER,
13089            project_root.display().to_string(),
13090            rel_path,
13091            hash,
13092            status,
13093            unix_seconds_now(),
13094        ],
13095    )?;
13096    Ok(())
13097}
13098
13099fn clear_backend_state_for_file(
13100    tx: &Transaction<'_>,
13101    project_root: &Path,
13102    rel_path: &str,
13103) -> Result<()> {
13104    tx.execute(
13105        "DELETE FROM backend_file_state
13106         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13107        params![
13108            BACKEND_TREESITTER,
13109            project_root.display().to_string(),
13110            rel_path
13111        ],
13112    )?;
13113    Ok(())
13114}
13115
13116/// Mark a file whose graph bytes were just confirmed current as fresh.
13117///
13118/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
13119/// leftover `status='stale'` row from a failed refresh would keep blocking
13120/// dead-code projection even though the graph still matches disk.
13121fn clear_stale_backend_status_for_file(
13122    tx: &Transaction<'_>,
13123    project_root: &Path,
13124    rel_path: &str,
13125) -> Result<()> {
13126    tx.execute(
13127        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13128         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13129        params![
13130            BACKEND_TREESITTER,
13131            project_root.display().to_string(),
13132            rel_path,
13133            unix_seconds_now(),
13134        ],
13135    )?;
13136    Ok(())
13137}
13138
13139fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13140    conn.query_row(
13141        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13142        params![rel_path],
13143        |row| {
13144            let hash_text: String = row.get(1)?;
13145            Ok(FileRow {
13146                surface_fingerprint: row.get(0)?,
13147                freshness: FileFreshness {
13148                    content_hash: hash_from_hex(&hash_text)
13149                        .unwrap_or_else(cache_freshness::zero_hash),
13150                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13151                    size: row.get::<_, i64>(3)? as u64,
13152                },
13153            })
13154        },
13155    )
13156    .optional()
13157    .map_err(CallGraphStoreError::from)
13158}
13159
13160fn stored_node_ids_match_extract(
13161    tx: &Transaction<'_>,
13162    rel_path: &str,
13163    extract: &FileExtract,
13164) -> Result<bool> {
13165    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13166    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13167    let mut stored = BTreeSet::new();
13168    for row in rows {
13169        stored.insert(row?);
13170    }
13171    let extracted = extract
13172        .nodes
13173        .iter()
13174        .map(|node| node.id.clone())
13175        .collect::<BTreeSet<_>>();
13176    Ok(stored == extracted)
13177}
13178
13179/// Compare every persisted graph row that comes from this file before rewriting it.
13180/// Ranges and reference byte offsets are part of the key because queries expose
13181/// source locations; equal names and edges are not enough after a body shift.
13182fn stored_extract_matches(
13183    tx: &Transaction<'_>,
13184    rel_path: &str,
13185    extract: &FileExtract,
13186    index: &ProjectIndex<'_>,
13187) -> Result<bool> {
13188    let stored_file = tx
13189        .query_row(
13190            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13191            params![rel_path],
13192            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13193        )
13194        .optional()?;
13195    if stored_file
13196        != Some((
13197            lang_label(extract.lang).to_string(),
13198            extract.surface_fingerprint.clone(),
13199        ))
13200    {
13201        return Ok(false);
13202    }
13203
13204    let mut stored_nodes_stmt = tx.prepare(
13205        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13206                end_line, end_col, range_ordinal, signature, exported,
13207                is_default_export, is_type_like, is_callgraph_entry_point, provenance
13208         FROM nodes WHERE file_path = ?1",
13209    )?;
13210    let stored_nodes = stored_nodes_stmt
13211        .query_map(params![rel_path], |row| {
13212            Ok(serde_json::json!([
13213                row.get::<_, String>(0)?,
13214                row.get::<_, String>(1)?,
13215                row.get::<_, String>(2)?,
13216                row.get::<_, String>(3)?,
13217                row.get::<_, String>(4)?,
13218                row.get::<_, i64>(5)?,
13219                row.get::<_, i64>(6)?,
13220                row.get::<_, i64>(7)?,
13221                row.get::<_, i64>(8)?,
13222                row.get::<_, i64>(9)?,
13223                row.get::<_, Option<String>>(10)?,
13224                row.get::<_, i64>(11)?,
13225                row.get::<_, i64>(12)?,
13226                row.get::<_, i64>(13)?,
13227                row.get::<_, i64>(14)?,
13228                row.get::<_, String>(15)?,
13229            ])
13230            .to_string())
13231        })?
13232        .collect::<rusqlite::Result<Vec<_>>>()?;
13233    let expected_nodes = extract
13234        .nodes
13235        .iter()
13236        .map(|node| {
13237            serde_json::json!([
13238                node.id,
13239                node.file_path,
13240                node.name,
13241                node.scoped_name,
13242                node.kind,
13243                node.range.start_line,
13244                node.range.start_col,
13245                node.range.end_line,
13246                node.range.end_col,
13247                node.range_ordinal,
13248                node.signature,
13249                bool_int(node.exported),
13250                bool_int(node.is_default_export),
13251                bool_int(node.is_type_like),
13252                bool_int(node.is_callgraph_entry_point),
13253                PROVENANCE_TREESITTER,
13254            ])
13255            .to_string()
13256        })
13257        .collect::<Vec<_>>();
13258    let mut stored_nodes = stored_nodes;
13259    let mut expected_nodes = expected_nodes;
13260    stored_nodes.sort();
13261    expected_nodes.sort();
13262    if stored_nodes != expected_nodes {
13263        return Ok(false);
13264    }
13265
13266    let resolved_refs = extract
13267        .raw_refs
13268        .iter()
13269        .cloned()
13270        .map(|raw| resolve_ref(raw, index))
13271        .collect::<Result<Vec<_>>>()?;
13272    let mut stored_refs_stmt = tx.prepare(
13273        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13274                module_path, import_kind, local_name, requested_name, namespace_alias,
13275                wildcard, line, byte_start, byte_end, status, target_node,
13276                target_file, target_symbol, provenance
13277         FROM refs WHERE caller_file = ?1",
13278    )?;
13279    let stored_refs = stored_refs_stmt
13280        .query_map(params![rel_path], |row| {
13281            Ok(serde_json::json!([
13282                row.get::<_, String>(0)?,
13283                row.get::<_, Option<String>>(1)?,
13284                row.get::<_, String>(2)?,
13285                row.get::<_, String>(3)?,
13286                row.get::<_, Option<String>>(4)?,
13287                row.get::<_, Option<String>>(5)?,
13288                row.get::<_, Option<String>>(6)?,
13289                row.get::<_, Option<String>>(7)?,
13290                row.get::<_, Option<String>>(8)?,
13291                row.get::<_, Option<String>>(9)?,
13292                row.get::<_, Option<String>>(10)?,
13293                row.get::<_, i64>(11)?,
13294                row.get::<_, i64>(12)?,
13295                row.get::<_, i64>(13)?,
13296                row.get::<_, i64>(14)?,
13297                row.get::<_, String>(15)?,
13298                row.get::<_, Option<String>>(16)?,
13299                row.get::<_, Option<String>>(17)?,
13300                row.get::<_, Option<String>>(18)?,
13301                row.get::<_, String>(19)?,
13302            ])
13303            .to_string())
13304        })?
13305        .collect::<rusqlite::Result<Vec<_>>>()?;
13306    let expected_refs = resolved_refs
13307        .iter()
13308        .map(|resolved| {
13309            let raw = &resolved.raw;
13310            serde_json::json!([
13311                raw.ref_id,
13312                raw.caller_node,
13313                raw.caller_file,
13314                raw.kind,
13315                raw.short_name,
13316                raw.full_ref,
13317                raw.module_path,
13318                raw.import_kind,
13319                raw.local_name,
13320                raw.requested_name,
13321                raw.namespace_alias,
13322                bool_int(raw.wildcard),
13323                raw.line,
13324                raw.byte_start,
13325                raw.byte_end,
13326                resolved.status,
13327                resolved.target_node,
13328                resolved.target_file,
13329                resolved.target_symbol,
13330                PROVENANCE_TREESITTER,
13331            ])
13332            .to_string()
13333        })
13334        .collect::<Vec<_>>();
13335    let mut stored_refs = stored_refs;
13336    let mut expected_refs = expected_refs;
13337    stored_refs.sort();
13338    expected_refs.sort();
13339    if stored_refs != expected_refs {
13340        return Ok(false);
13341    }
13342
13343    let mut stored_edges_stmt = tx.prepare(
13344        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13345                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13346         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13347         WHERE r.caller_file = ?1 AND e.provenance = ?2",
13348    )?;
13349    let stored_edges = stored_edges_stmt
13350        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13351            Ok(serde_json::json!([
13352                row.get::<_, String>(0)?,
13353                row.get::<_, String>(1)?,
13354                row.get::<_, String>(2)?,
13355                row.get::<_, Option<String>>(3)?,
13356                row.get::<_, String>(4)?,
13357                row.get::<_, String>(5)?,
13358                row.get::<_, String>(6)?,
13359                row.get::<_, i64>(7)?,
13360                row.get::<_, String>(8)?,
13361            ])
13362            .to_string())
13363        })?
13364        .collect::<rusqlite::Result<Vec<_>>>()?;
13365    let expected_edges = resolved_refs
13366        .iter()
13367        .filter_map(|resolved| {
13368            resolved.edge.as_ref().map(|edge| {
13369                serde_json::json!([
13370                    edge.edge_id,
13371                    resolved.raw.ref_id,
13372                    edge.source_node,
13373                    edge.target_node,
13374                    edge.target_file,
13375                    edge.target_symbol,
13376                    edge.kind,
13377                    edge.line,
13378                    PROVENANCE_TREESITTER,
13379                ])
13380                .to_string()
13381            })
13382        })
13383        .collect::<Vec<_>>();
13384    let mut stored_edges = stored_edges;
13385    let mut expected_edges = expected_edges;
13386    stored_edges.sort();
13387    expected_edges.sort();
13388    if stored_edges != expected_edges {
13389        return Ok(false);
13390    }
13391
13392    let mut stored_dependencies_stmt =
13393        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
13394    let stored_dependencies = stored_dependencies_stmt
13395        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
13396        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
13397    let expected_dependencies = extract
13398        .raw_refs
13399        .iter()
13400        .flat_map(|raw| raw.dependencies.iter().cloned())
13401        .collect::<BTreeSet<_>>();
13402    if stored_dependencies != expected_dependencies {
13403        return Ok(false);
13404    }
13405
13406    let mut stored_hints_stmt = tx.prepare(
13407        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
13408         FROM dispatch_hints WHERE file = ?1",
13409    )?;
13410    let stored_hints = stored_hints_stmt
13411        .query_map(params![rel_path], |row| {
13412            Ok(serde_json::json!([
13413                row.get::<_, String>(0)?,
13414                row.get::<_, String>(1)?,
13415                row.get::<_, String>(2)?,
13416                row.get::<_, String>(3)?,
13417                row.get::<_, i64>(4)?,
13418                row.get::<_, i64>(5)?,
13419                row.get::<_, i64>(6)?,
13420                row.get::<_, String>(7)?,
13421            ])
13422            .to_string())
13423        })?
13424        .collect::<rusqlite::Result<Vec<_>>>()?;
13425    let expected_hints = extract
13426        .dispatch_hints
13427        .iter()
13428        .map(|hint| {
13429            serde_json::json!([
13430                hint.id,
13431                hint.method_name,
13432                hint.caller_node,
13433                hint.file,
13434                hint.line,
13435                hint.byte_start,
13436                hint.byte_end,
13437                PROVENANCE_TREESITTER,
13438            ])
13439            .to_string()
13440        })
13441        .collect::<Vec<_>>();
13442    let mut stored_hints = stored_hints;
13443    let mut expected_hints = expected_hints;
13444    stored_hints.sort();
13445    expected_hints.sort();
13446    Ok(stored_hints == expected_hints)
13447}
13448
13449fn update_file_fresh_metadata(
13450    tx: &Transaction<'_>,
13451    project_root: &Path,
13452    rel_path: &str,
13453    hash: &blake3::Hash,
13454    mtime: SystemTime,
13455    size: u64,
13456) -> Result<()> {
13457    tx.execute(
13458        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
13459         WHERE path = ?1",
13460        params![
13461            rel_path,
13462            hash_to_hex(*hash),
13463            system_time_to_ns(mtime),
13464            size as i64,
13465            unix_seconds_now()
13466        ],
13467    )?;
13468    tx.execute(
13469        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
13470         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
13471        params![
13472            BACKEND_TREESITTER,
13473            rel_path,
13474            hash_to_hex(*hash),
13475            project_root.display().to_string(),
13476            unix_seconds_now(),
13477        ],
13478    )?;
13479    Ok(())
13480}
13481
13482#[derive(Debug, Clone, PartialEq, Eq)]
13483struct DependentRefSelection {
13484    ref_id: String,
13485    caller_file: String,
13486}
13487
13488fn ref_ids_depending_on(
13489    conn: &Connection,
13490    project_root: &Path,
13491    rel_path: &str,
13492) -> Result<Vec<DependentRefSelection>> {
13493    let mut stmt = conn.prepare(
13494        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
13495         FROM refs r
13496         WHERE r.caller_file IN (
13497             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
13498         )
13499            OR r.target_file = ?1
13500         ORDER BY r.ref_id",
13501    )?;
13502    let rows = stmt.query_map(params![rel_path], |row| {
13503        Ok(RefDependencyRow {
13504            ref_id: row.get(0)?,
13505            kind: row.get(1)?,
13506            caller_file: row.get(2)?,
13507            module_path: row.get(3)?,
13508            target_file: row.get(4)?,
13509        })
13510    })?;
13511    let mut ids = Vec::new();
13512    for row in rows {
13513        let row = row?;
13514        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
13515            ids.push(DependentRefSelection {
13516                ref_id: row.ref_id,
13517                caller_file: row.caller_file,
13518            });
13519        }
13520    }
13521    Ok(ids)
13522}
13523
13524fn record_dependent_refs(
13525    selected_ref_ids: &mut BTreeSet<String>,
13526    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
13527    dependent_refs: Vec<DependentRefSelection>,
13528) {
13529    for dependent_ref in dependent_refs {
13530        let DependentRefSelection {
13531            ref_id,
13532            caller_file,
13533        } = dependent_ref;
13534        selected_ref_ids.insert(ref_id.clone());
13535        selected_refs_by_caller
13536            .entry(caller_file)
13537            .or_default()
13538            .insert(ref_id);
13539    }
13540}
13541
13542#[cfg(test)]
13543fn refs_by_caller_for_ref_ids(
13544    tx: &Transaction<'_>,
13545    ref_ids: &BTreeSet<String>,
13546) -> Result<BTreeMap<String, BTreeSet<String>>> {
13547    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13548    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
13549    for ref_id in ref_ids {
13550        if let Some(caller) = stmt
13551            .query_row(params![ref_id], |row| row.get::<_, String>(0))
13552            .optional()?
13553        {
13554            by_caller.entry(caller).or_default().insert(ref_id.clone());
13555        }
13556    }
13557    Ok(by_caller)
13558}
13559
13560fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13561    tx.execute(
13562        "DELETE FROM file_dependencies WHERE file_path = ?1",
13563        params![rel_path],
13564    )?;
13565    delete_refs_for_caller(tx, rel_path)?;
13566    tx.execute(
13567        "DELETE FROM dispatch_hints WHERE file = ?1",
13568        params![rel_path],
13569    )?;
13570    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
13571    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
13572    Ok(())
13573}
13574
13575fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
13576    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
13577    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13578    let mut ids = BTreeSet::new();
13579    for row in rows {
13580        ids.insert(row?);
13581    }
13582    delete_ref_ids(tx, &ids)
13583}
13584
13585fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
13586    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
13587    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
13588    for ref_id in ref_ids {
13589        delete_edges.execute(params![ref_id])?;
13590        delete_refs.execute(params![ref_id])?;
13591    }
13592    Ok(())
13593}
13594
13595fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
13596    let mut stmt = conn.prepare(
13597        "SELECT source.file_path, source.scoped_name, edges.target_file,
13598                edges.target_symbol, edges.kind, edges.line
13599         FROM edges
13600         JOIN nodes AS source ON source.id = edges.source_node
13601         ORDER BY source.file_path, source.scoped_name, edges.target_file,
13602                  edges.target_symbol, edges.kind, edges.line",
13603    )?;
13604    let rows = stmt.query_map([], |row| {
13605        Ok(StoredEdge {
13606            source_file: row.get(0)?,
13607            source_symbol: row.get(1)?,
13608            target_file: row.get(2)?,
13609            target_symbol: row.get(3)?,
13610            kind: row.get(4)?,
13611            line: row.get::<_, i64>(5)? as u32,
13612        })
13613    })?;
13614    let mut edges = BTreeSet::new();
13615    for row in rows {
13616        edges.insert(row?);
13617    }
13618    Ok(edges)
13619}
13620
13621fn module_target_from_dependencies(
13622    project_root: &Path,
13623    dependencies: &BTreeSet<String>,
13624) -> Option<String> {
13625    dependencies.iter().find_map(|dep| {
13626        let path = project_root.join(dep);
13627        if path.is_file() {
13628            Some(relative_path(project_root, &canonicalize_path(&path)))
13629        } else {
13630            None
13631        }
13632    })
13633}
13634
13635fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
13636    let mut named = HashMap::new();
13637    if let Some(full_ref) = &raw_ref.full_ref {
13638        named = parse_reexport_names(full_ref);
13639    }
13640    ReexportIndex {
13641        target_file,
13642        named,
13643        wildcard: raw_ref.wildcard,
13644    }
13645}
13646
13647fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
13648    let mut names = HashMap::new();
13649    let Some(open) = statement.find('{') else {
13650        return names;
13651    };
13652    let Some(close) = statement[open + 1..]
13653        .find('}')
13654        .map(|offset| open + 1 + offset)
13655    else {
13656        return names;
13657    };
13658    for spec in statement[open + 1..close].split(',') {
13659        let spec = spec.trim();
13660        if spec.is_empty() {
13661            continue;
13662        }
13663        if let Some((source, local)) = spec.split_once(" as ") {
13664            names.insert(local.trim().to_string(), source.trim().to_string());
13665        } else {
13666            names.insert(spec.to_string(), spec.to_string());
13667        }
13668    }
13669    names
13670}
13671
13672#[derive(Debug)]
13673struct RefDependencyRow {
13674    ref_id: String,
13675    kind: String,
13676    caller_file: String,
13677    module_path: Option<String>,
13678    target_file: Option<String>,
13679}
13680
13681fn ref_dependency_row_depends_on(
13682    project_root: &Path,
13683    row: &RefDependencyRow,
13684    rel_path: &str,
13685) -> bool {
13686    if row.target_file.as_deref() == Some(rel_path) {
13687        return true;
13688    }
13689
13690    match row.kind.as_str() {
13691        "call" => true,
13692        "import" | "reexport" => row
13693            .module_path
13694            .as_deref()
13695            .map(|module_path| {
13696                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
13697                    .contains(rel_path)
13698            })
13699            .unwrap_or(false),
13700        "export_alias" => false,
13701        _ => false,
13702    }
13703}
13704
13705fn module_dependencies_for_ref(
13706    project_root: &Path,
13707    caller_file: &str,
13708    module_path: &str,
13709) -> BTreeSet<String> {
13710    module_dependencies(project_root, &project_root.join(caller_file), module_path)
13711}
13712
13713fn import_dependencies(
13714    project_root: &Path,
13715    abs_path: &Path,
13716    imports: &[ImportStatement],
13717) -> BTreeSet<String> {
13718    let mut deps = BTreeSet::new();
13719    for import in imports {
13720        deps.extend(module_dependencies(
13721            project_root,
13722            abs_path,
13723            &import.module_path,
13724        ));
13725    }
13726    deps
13727}
13728
13729fn module_dependencies(
13730    project_root: &Path,
13731    abs_path: &Path,
13732    module_path: &str,
13733) -> BTreeSet<String> {
13734    let mut deps = rust_module_dependencies(project_root, abs_path, module_path);
13735    let caller_dir = abs_path.parent().unwrap_or(project_root);
13736    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
13737        deps.insert(relative_path(project_root, &resolved));
13738    }
13739    if module_path.starts_with('.') {
13740        let base = caller_dir.join(module_path);
13741        for candidate in relative_module_candidates(&base) {
13742            deps.insert(relative_path(project_root, &candidate));
13743        }
13744    }
13745    deps
13746}
13747
13748fn rust_module_dependencies(
13749    project_root: &Path,
13750    abs_path: &Path,
13751    module_path: &str,
13752) -> BTreeSet<String> {
13753    let mut deps = BTreeSet::new();
13754    let rel_path = relative_path(project_root, &canonicalize_path(abs_path));
13755    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
13756        return deps;
13757    };
13758    let src_prefix = rust_src_prefix(&rel_path);
13759    rust_push_module_dependency_candidate(project_root, &mut deps, &src_prefix, &path_segments);
13760    if !path_segments.is_empty() {
13761        rust_push_module_dependency_candidate(
13762            project_root,
13763            &mut deps,
13764            &src_prefix,
13765            &path_segments[..path_segments.len() - 1],
13766        );
13767    }
13768    deps
13769}
13770
13771fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
13772    let path = rust_module_path_without_alias_or_use_list(module_path);
13773    let segments = path
13774        .split("::")
13775        .map(str::trim)
13776        .filter(|segment| !segment.is_empty())
13777        .collect::<Vec<_>>();
13778    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
13779        return None;
13780    }
13781    rust_resolve_segments(rel_path, &segments)
13782}
13783
13784fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
13785    let path = module_path
13786        .trim()
13787        .trim_end_matches(';')
13788        .split_once(" as ")
13789        .map(|(left, _)| left.trim())
13790        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
13791    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
13792}
13793
13794fn rust_push_module_dependency_candidate(
13795    project_root: &Path,
13796    deps: &mut BTreeSet<String>,
13797    src_prefix: &str,
13798    segments: &[String],
13799) {
13800    let candidates = if segments.is_empty() {
13801        vec![
13802            format!("{src_prefix}/lib.rs"),
13803            format!("{src_prefix}/main.rs"),
13804        ]
13805    } else {
13806        vec![
13807            format!("{}/{}.rs", src_prefix, segments.join("/")),
13808            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
13809        ]
13810    };
13811    for candidate in candidates {
13812        if project_root.join(&candidate).is_file() {
13813            deps.insert(candidate);
13814        }
13815    }
13816}
13817
13818fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
13819    let mut candidates = Vec::new();
13820    if base.extension().is_some() {
13821        candidates.push(base.to_path_buf());
13822        return candidates;
13823    }
13824    for ext in JS_TS_EXTENSIONS {
13825        candidates.push(base.with_extension(ext));
13826    }
13827    for ext in JS_TS_EXTENSIONS {
13828        candidates.push(base.join(format!("index.{ext}")));
13829    }
13830    candidates
13831}
13832
13833fn import_local_names(import: &ImportStatement) -> Vec<String> {
13834    let mut names = Vec::new();
13835    if let Some(default) = &import.default_import {
13836        names.push(default.clone());
13837    }
13838    if let Some(namespace) = &import.namespace_import {
13839        names.push(namespace.clone());
13840    }
13841    for name in &import.names {
13842        names.push(crate::imports::specifier_local_name(name).to_string());
13843    }
13844    names
13845}
13846
13847fn import_requested_names(import: &ImportStatement) -> Vec<String> {
13848    import
13849        .names
13850        .iter()
13851        .map(|name| crate::imports::specifier_imported_name(name).to_string())
13852        .collect()
13853}
13854
13855fn import_is_wildcard(import: &ImportStatement) -> bool {
13856    import.namespace_import.is_some() || import.raw_text.contains('*')
13857}
13858
13859fn namespace_alias(full_ref: &str) -> Option<String> {
13860    full_ref
13861        .split_once('.')
13862        .map(|(namespace, _)| namespace.to_string())
13863}
13864
13865fn import_kind_label(kind: ImportKind) -> &'static str {
13866    match kind {
13867        ImportKind::Value => "value",
13868        ImportKind::Type => "type",
13869        ImportKind::SideEffect => "side_effect",
13870    }
13871}
13872
13873fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
13874    match kind {
13875        SymbolKind::Function => "function",
13876        SymbolKind::Class => "class",
13877        SymbolKind::Method => "method",
13878        SymbolKind::Struct => "struct",
13879        SymbolKind::Interface => "interface",
13880        SymbolKind::Enum => "enum",
13881        SymbolKind::TypeAlias => "type_alias",
13882        SymbolKind::Variable => "variable",
13883        SymbolKind::Heading => "heading",
13884        SymbolKind::FileSummary => "file_summary",
13885    }
13886}
13887
13888fn is_type_like(kind: &SymbolKind) -> bool {
13889    matches!(
13890        kind,
13891        SymbolKind::Class
13892            | SymbolKind::Struct
13893            | SymbolKind::Interface
13894            | SymbolKind::Enum
13895            | SymbolKind::TypeAlias
13896    )
13897}
13898
13899fn lang_label(lang: LangId) -> &'static str {
13900    match lang {
13901        LangId::TypeScript => "typescript",
13902        LangId::Tsx => "tsx",
13903        LangId::JavaScript => "javascript",
13904        LangId::Python => "python",
13905        LangId::Rust => "rust",
13906        LangId::Go => "go",
13907        LangId::C => "c",
13908        LangId::Cpp => "cpp",
13909        LangId::Zig => "zig",
13910        LangId::CSharp => "csharp",
13911        LangId::Bash => "bash",
13912        LangId::Html => "html",
13913        LangId::Markdown => "markdown",
13914        LangId::Solidity => "solidity",
13915        LangId::Scss => "scss",
13916        LangId::Vue => "vue",
13917        LangId::Json => "json",
13918        LangId::Scala => "scala",
13919        LangId::Java => "java",
13920        LangId::Ruby => "ruby",
13921        LangId::Kotlin => "kotlin",
13922        LangId::Swift => "swift",
13923        LangId::Php => "php",
13924        LangId::Lua => "lua",
13925        LangId::Perl => "perl",
13926        LangId::Yaml => "yaml",
13927        LangId::Pascal => "pascal",
13928        LangId::R => "r",
13929        LangId::Groovy => "groovy",
13930        LangId::ObjC => "objc",
13931    }
13932}
13933
13934fn lang_from_label(label: &str) -> Option<LangId> {
13935    match label {
13936        "typescript" => Some(LangId::TypeScript),
13937        "tsx" => Some(LangId::Tsx),
13938        "javascript" => Some(LangId::JavaScript),
13939        "python" => Some(LangId::Python),
13940        "rust" => Some(LangId::Rust),
13941        "go" => Some(LangId::Go),
13942        "c" => Some(LangId::C),
13943        "cpp" => Some(LangId::Cpp),
13944        "zig" => Some(LangId::Zig),
13945        "csharp" => Some(LangId::CSharp),
13946        "bash" => Some(LangId::Bash),
13947        "html" => Some(LangId::Html),
13948        "markdown" => Some(LangId::Markdown),
13949        "solidity" => Some(LangId::Solidity),
13950        "scss" => Some(LangId::Scss),
13951        "vue" => Some(LangId::Vue),
13952        "json" => Some(LangId::Json),
13953        "scala" => Some(LangId::Scala),
13954        "java" => Some(LangId::Java),
13955        "ruby" => Some(LangId::Ruby),
13956        "kotlin" => Some(LangId::Kotlin),
13957        "swift" => Some(LangId::Swift),
13958        "php" => Some(LangId::Php),
13959        "lua" => Some(LangId::Lua),
13960        "perl" => Some(LangId::Perl),
13961        "yaml" => Some(LangId::Yaml),
13962        "pascal" => Some(LangId::Pascal),
13963        "r" => Some(LangId::R),
13964        "groovy" => Some(LangId::Groovy),
13965        "objc" => Some(LangId::ObjC),
13966        _ => None,
13967    }
13968}
13969
13970fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
13971    let mut normalized = if files.is_empty() {
13972        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
13973    } else {
13974        files
13975            .iter()
13976            .map(|path| normalize_file_path(project_root, path))
13977            .collect::<Result<Vec<_>>>()?
13978    };
13979    normalized.sort();
13980    normalized.dedup();
13981    Ok(normalized)
13982}
13983
13984fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
13985    let full_path = if path.is_relative() {
13986        project_root.join(path)
13987    } else {
13988        path.to_path_buf()
13989    };
13990    Ok(canonicalize_path(&full_path))
13991}
13992
13993/// Normalize a refresh path against the store root before assigning its durable
13994/// relative key. Deleted watcher paths need lenient canonicalization: their
13995/// parent can still reveal an alias such as a symlinked project root.
13996fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
13997    let abs_path = normalize_file_path(project_root, path)?;
13998    let rel_path = relative_path(project_root, &abs_path);
13999    if Path::new(&rel_path).is_absolute() {
14000        return Err(CallGraphStoreError::PathIdentityMismatch {
14001            path: path.to_path_buf(),
14002            project_root: project_root.to_path_buf(),
14003        });
14004    }
14005    Ok((abs_path, rel_path))
14006}
14007
14008/// Canonicalize an existing path or the deepest existing ancestor of a deleted
14009/// one. This keeps watcher deletion events in the same identity domain as the
14010/// files indexed before the deletion.
14011fn canonicalize_path(path: &Path) -> PathBuf {
14012    if let Ok(canonical) = std::fs::canonicalize(path) {
14013        return canonical;
14014    }
14015
14016    let mut resolved = PathBuf::new();
14017    let mut missing = Vec::new();
14018    for component in path.components() {
14019        match component {
14020            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
14021                resolved.push(component.as_os_str());
14022                if let Ok(canonical) = std::fs::canonicalize(&resolved) {
14023                    resolved = canonical;
14024                }
14025            }
14026            std::path::Component::CurDir => {}
14027            std::path::Component::ParentDir => {
14028                if missing.pop().is_none() {
14029                    if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
14030                        return path.to_path_buf();
14031                    }
14032                    resolved.pop();
14033                }
14034            }
14035            std::path::Component::Normal(name) => {
14036                if missing.is_empty() {
14037                    let candidate = resolved.join(name);
14038                    match std::fs::canonicalize(&candidate) {
14039                        Ok(canonical) => resolved = canonical,
14040                        Err(_) => match std::fs::symlink_metadata(&candidate) {
14041                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14042                                missing.push(name.to_owned());
14043                            }
14044                            _ => return path.to_path_buf(),
14045                        },
14046                    }
14047                } else {
14048                    missing.push(name.to_owned());
14049                }
14050            }
14051        }
14052    }
14053    resolved.extend(missing);
14054    resolved
14055}
14056
14057fn relative_path(project_root: &Path, path: &Path) -> String {
14058    if let Ok(stripped) = path.strip_prefix(project_root) {
14059        return stripped.to_string_lossy().replace('\\', "/");
14060    }
14061    let canon_root = canonicalize_path(project_root);
14062    let canon_path = canonicalize_path(path);
14063    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
14064        return stripped.to_string_lossy().replace('\\', "/");
14065    }
14066    canon_path.to_string_lossy().replace('\\', "/")
14067}
14068
14069fn unqualified_name(scoped: &str) -> &str {
14070    if scoped == TOP_LEVEL_SYMBOL {
14071        return scoped;
14072    }
14073    scoped
14074        .rsplit("::")
14075        .next()
14076        .unwrap_or(scoped)
14077        .rsplit('.')
14078        .next()
14079        .unwrap_or(scoped)
14080        .rsplit('#')
14081        .next()
14082        .unwrap_or(scoped)
14083}
14084
14085fn ref_id(parts: &[&str]) -> String {
14086    let joined = parts.join("\0");
14087    hash_to_hex(blake3::hash(joined.as_bytes()))
14088}
14089
14090fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
14091    let mut fingerprint = CorpusFingerprint::default();
14092    for path in callgraph::walk_project_files(project_root) {
14093        fingerprint.add_path(project_root, &path);
14094    }
14095    Ok(fingerprint.finish(project_root))
14096}
14097
14098/// Pre-admission fingerprint over the same source set the staging inventory
14099/// will consume: the walk when no explicit list is supplied, the list
14100/// otherwise. Streaming accumulator - no staging writes, bounded memory.
14101fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
14102    if files.is_empty() {
14103        return callgraph_corpus_fingerprint(project_root);
14104    }
14105    let mut fingerprint = CorpusFingerprint::default();
14106    for path in files {
14107        fingerprint.add_path(project_root, path);
14108    }
14109    Ok(fingerprint.finish(project_root))
14110}
14111
14112#[derive(Default)]
14113struct CorpusFingerprint {
14114    xor: [u8; 32],
14115    sums: [u64; 4],
14116    files: u64,
14117}
14118
14119impl CorpusFingerprint {
14120    fn add_path(&mut self, project_root: &Path, path: &Path) {
14121        let mut record = blake3::Hasher::new();
14122        record.update(relative_path(project_root, path).as_bytes());
14123        record.update(&[0]);
14124        match hash_file_bounded(path) {
14125            Ok(content_hash) => record.update(content_hash.as_bytes()),
14126            // Encoding a missing file as a distinct record changes the corpus
14127            // fingerprint, so breaker state keyed to the previous corpus is not reused.
14128            Err(error) => record.update(format!("missing:{error}").as_bytes()),
14129        };
14130        record.update(&[0]);
14131        let record = record.finalize();
14132        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14133            self.xor[index] ^= byte;
14134        }
14135        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14136            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14137            self.sums[index] = self.sums[index].wrapping_add(value);
14138        }
14139        self.files = self.files.saturating_add(1);
14140    }
14141
14142    fn finish(self, project_root: &Path) -> String {
14143        // Combining both xor and modular sums keeps the digest independent of
14144        // walk order while retaining duplicate sensitivity for generic callers.
14145        let mut hasher = blake3::Hasher::new();
14146        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14147        hasher.update(&self.files.to_le_bytes());
14148        hasher.update(&self.xor);
14149        for sum in self.sums {
14150            hasher.update(&sum.to_le_bytes());
14151        }
14152        let ignore_rules = project_root.join(".gitignore");
14153        if let Ok(contents) = std::fs::read(ignore_rules) {
14154            hasher.update(b".gitignore\0");
14155            hasher.update(blake3::hash(&contents).as_bytes());
14156        }
14157        hash_to_hex(hasher.finalize())
14158    }
14159}
14160
14161fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14162    let mut file = std::fs::File::open(path)?;
14163    let mut hasher = blake3::Hasher::new();
14164    let mut buffer = [0u8; 64 * 1024];
14165    loop {
14166        let read = file.read(&mut buffer)?;
14167        if read == 0 {
14168            break;
14169        }
14170        hasher.update(&buffer[..read]);
14171    }
14172    Ok(hasher.finalize())
14173}
14174
14175#[cfg(test)]
14176pub(crate) fn callgraph_corpus_fingerprint_for_test(
14177    project_root: &Path,
14178    _files: &[PathBuf],
14179) -> Result<String> {
14180    // The streaming fingerprint walks the corpus itself (order-independent
14181    // accumulator, no resident file list); the test seam keeps its historical
14182    // signature so callers need not thread a walk of their own.
14183    callgraph_corpus_fingerprint(project_root)
14184}
14185
14186fn hash_to_hex(hash: blake3::Hash) -> String {
14187    hash.to_hex().to_string()
14188}
14189
14190fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14191    let bytes = hex_to_bytes(value)?;
14192    Some(blake3::Hash::from_bytes(bytes))
14193}
14194
14195fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14196    if value.len() != 64 {
14197        return None;
14198    }
14199    let mut bytes = [0u8; 32];
14200    for (index, slot) in bytes.iter_mut().enumerate() {
14201        let start = index * 2;
14202        let end = start + 2;
14203        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14204    }
14205    Some(bytes)
14206}
14207
14208#[derive(Debug, Clone)]
14209struct LineIndex {
14210    newline_offsets: Vec<usize>,
14211    source_len: usize,
14212}
14213
14214impl LineIndex {
14215    fn new(source: &str) -> Self {
14216        Self {
14217            newline_offsets: source
14218                .bytes()
14219                .enumerate()
14220                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14221                .collect(),
14222            source_len: source.len(),
14223        }
14224    }
14225
14226    fn byte_to_line(&self, byte_offset: usize) -> u32 {
14227        let byte_offset = byte_offset.min(self.source_len);
14228        self.newline_offsets
14229            .partition_point(|offset| *offset < byte_offset) as u32
14230            + 1
14231    }
14232}
14233
14234fn empty_to_none(value: String) -> Option<String> {
14235    if value.is_empty() {
14236        None
14237    } else {
14238        Some(value)
14239    }
14240}
14241
14242fn bool_int(value: bool) -> i64 {
14243    if value {
14244        1
14245    } else {
14246        0
14247    }
14248}
14249
14250fn system_time_to_ns(time: SystemTime) -> i64 {
14251    time.duration_since(UNIX_EPOCH)
14252        .unwrap_or_default()
14253        .as_nanos()
14254        .min(i64::MAX as u128) as i64
14255}
14256
14257fn ns_to_system_time(value: i64) -> SystemTime {
14258    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14259}
14260
14261pub(crate) fn unix_millis_now() -> u64 {
14262    SystemTime::now()
14263        .duration_since(UNIX_EPOCH)
14264        .unwrap_or_default()
14265        .as_millis()
14266        .min(u128::from(u64::MAX)) as u64
14267}
14268
14269fn unix_seconds_now() -> i64 {
14270    SystemTime::now()
14271        .duration_since(UNIX_EPOCH)
14272        .unwrap_or_default()
14273        .as_secs() as i64
14274}
14275
14276/// Serializes every test that drives the process-wide refresh worker
14277/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
14278/// worker down between another test's enqueue and its flush, deferring the
14279/// batch and zeroing that test's seam counts).
14280#[cfg(test)]
14281pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14282
14283#[cfg(test)]
14284mod refresh_worker_tests {
14285    use super::*;
14286    use std::fs;
14287    use tempfile::tempdir;
14288
14289    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14290        let temp = tempdir().unwrap();
14291        let root = temp.path().join("root");
14292        fs::create_dir_all(&root).unwrap();
14293        let artifact_key = crate::search_index::artifact_cache_key(&root);
14294        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14295        let callgraph_dir = temp
14296            .path()
14297            .join("storage")
14298            .join("callgraph")
14299            .join(artifact_key);
14300        let source = root.join("main.rs");
14301        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14302        let (store, _) = CallGraphStore::cold_build_with_lease(
14303            callgraph_dir.clone(),
14304            root.clone(),
14305            std::slice::from_ref(&source),
14306        )
14307        .unwrap();
14308        drop(store);
14309        (temp, root, callgraph_dir, source)
14310    }
14311
14312    fn pending_paths() -> PendingCallGraphStorePaths {
14313        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14314    }
14315
14316    fn wait_for_refresh_calls(root: &Path, expected: usize) {
14317        let deadline = Instant::now() + Duration::from_secs(12);
14318        while callgraph_refresh_worker_test_counts(root).0 < expected {
14319            assert!(
14320                Instant::now() < deadline,
14321                "timed out waiting for {expected} callgraph refresh worker call(s)"
14322            );
14323            std::thread::sleep(Duration::from_millis(5));
14324        }
14325    }
14326
14327    fn wait_for_refresh_worker_idle() {
14328        let deadline = Instant::now() + Duration::from_secs(12);
14329        loop {
14330            let worker = CALLGRAPH_REFRESH_WORKER
14331                .get_or_init(|| Mutex::new(None))
14332                .lock()
14333                .expect("callgraph refresh worker mutex poisoned")
14334                .clone();
14335            let idle = worker.is_none_or(|worker| {
14336                let queue = worker
14337                    .shared
14338                    .queue
14339                    .lock()
14340                    .expect("callgraph refresh queue mutex poisoned");
14341                queue.active.is_none() && queue.order.is_empty()
14342            });
14343            if idle {
14344                return;
14345            }
14346            assert!(
14347                Instant::now() < deadline,
14348                "timed out waiting for callgraph refresh worker to become idle"
14349            );
14350            std::thread::sleep(Duration::from_millis(5));
14351        }
14352    }
14353
14354    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14355        let temp = tempdir().unwrap();
14356        let root = temp.path().join("workspace");
14357        fs::create_dir_all(root.join("app/src")).unwrap();
14358        let artifact_key = crate::search_index::artifact_cache_key(&root);
14359        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14360        let callgraph_dir = temp
14361            .path()
14362            .join("storage")
14363            .join("callgraph")
14364            .join(artifact_key);
14365        fs::write(
14366            root.join("Cargo.toml"),
14367            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
14368        )
14369        .unwrap();
14370        fs::write(
14371            root.join("app/Cargo.toml"),
14372            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14373        )
14374        .unwrap();
14375        let caller = root.join("app/src/lib.rs");
14376        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
14377        let (store, _) = CallGraphStore::cold_build_with_lease(
14378            callgraph_dir.clone(),
14379            root.clone(),
14380            std::slice::from_ref(&caller),
14381        )
14382        .unwrap();
14383        drop(store);
14384        (temp, root, callgraph_dir, caller)
14385    }
14386
14387    #[test]
14388    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
14389        let _guard = REFRESH_WORKER_TEST_LOCK
14390            .lock()
14391            .unwrap_or_else(std::sync::PoisonError::into_inner);
14392        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14393        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14394        reset_workspace_crate_prefix_build_count(&root);
14395        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14396
14397        for revision in ["first", "second"] {
14398            fs::write(
14399                &caller,
14400                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
14401            )
14402            .unwrap();
14403            enqueue_callgraph_store_refresh(
14404                callgraph_dir.clone(),
14405                root.clone(),
14406                vec![caller.clone()],
14407                pending_paths(),
14408            );
14409            wait_for_refresh_worker_idle();
14410        }
14411
14412        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14413        assert!(flush_callgraph_store_refreshes_with_budget(
14414            Duration::from_secs(5)
14415        ));
14416        clear_callgraph_refresh_worker_test_seam(&root);
14417    }
14418
14419    #[test]
14420    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
14421        let _guard = REFRESH_WORKER_TEST_LOCK
14422            .lock()
14423            .unwrap_or_else(std::sync::PoisonError::into_inner);
14424        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14425        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14426        reset_workspace_crate_prefix_build_count(&root);
14427        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14428
14429        fs::write(
14430            &caller,
14431            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
14432        )
14433        .unwrap();
14434        enqueue_callgraph_store_refresh(
14435            callgraph_dir.clone(),
14436            root.clone(),
14437            vec![caller.clone()],
14438            pending_paths(),
14439        );
14440        wait_for_refresh_worker_idle();
14441        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14442
14443        let added_manifest = root.join("added/Cargo.toml");
14444        let added_source = root.join("added/src/lib.rs");
14445        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
14446        fs::write(
14447            root.join("Cargo.toml"),
14448            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
14449        )
14450        .unwrap();
14451        fs::write(
14452            &added_manifest,
14453            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14454        )
14455        .unwrap();
14456        fs::write(&added_source, "pub fn target() {}\n").unwrap();
14457        fs::write(
14458            &caller,
14459            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
14460        )
14461        .unwrap();
14462
14463        enqueue_callgraph_store_refresh(
14464            callgraph_dir.clone(),
14465            root.clone(),
14466            vec![
14467                root.join("Cargo.toml"),
14468                added_manifest,
14469                added_source,
14470                caller,
14471            ],
14472            pending_paths(),
14473        );
14474        assert!(flush_callgraph_store_refreshes_with_budget(
14475            Duration::from_secs(12)
14476        ));
14477
14478        // This is the negative control for a permanently-static cache: without
14479        // manifest invalidation the build count stays at one and the call remains
14480        // unresolved because `added_crate` was absent when the map was primed.
14481        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
14482        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14483            .unwrap()
14484            .expect("refreshed workspace store");
14485        let tree = store
14486            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
14487            .unwrap();
14488        assert_eq!(tree.children.len(), 1);
14489        assert_eq!(tree.children[0].file, "added/src/lib.rs");
14490        assert_eq!(tree.children[0].name, "target");
14491        assert!(tree.children[0].resolved);
14492        clear_callgraph_refresh_worker_test_seam(&root);
14493    }
14494
14495    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
14496        let temp = tempdir().unwrap();
14497        let main = temp.path().join("main");
14498        let worktree = temp.path().join("worktree");
14499        fs::create_dir_all(&main).unwrap();
14500        let mut git = std::process::Command::new("git");
14501        assert!(
14502            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
14503                .status()
14504                .unwrap()
14505                .success()
14506        );
14507        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
14508        for args in [
14509            vec![
14510                "-C",
14511                main.to_str().unwrap(),
14512                "config",
14513                "user.email",
14514                "test@example.com",
14515            ],
14516            vec![
14517                "-C",
14518                main.to_str().unwrap(),
14519                "config",
14520                "user.name",
14521                "AFT Test",
14522            ],
14523            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
14524            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
14525        ] {
14526            let mut command = std::process::Command::new("git");
14527            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
14528                .status()
14529                .unwrap()
14530                .success());
14531        }
14532        let mut add_worktree = std::process::Command::new("git");
14533        assert!(crate::test_env::apply_hermetic_git_env(
14534            add_worktree
14535                .arg("-C")
14536                .arg(&main)
14537                .args(["worktree", "add", "--detach"])
14538                .arg(&worktree),
14539        )
14540        .status()
14541        .unwrap()
14542        .success());
14543        let main = fs::canonicalize(main).unwrap();
14544        let worktree = fs::canonicalize(worktree).unwrap();
14545        let project_key = crate::search_index::artifact_cache_key(&main);
14546        assert_eq!(
14547            crate::search_index::artifact_cache_key(&worktree),
14548            project_key
14549        );
14550        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14551        (temp, main, worktree, project_key, callgraph_dir)
14552    }
14553
14554    #[test]
14555    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
14556        let _git_env = crate::test_env::hermetic_git_env_guard();
14557        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
14558        crate::root_cache::configure_artifact_access(&root, &project_key, true);
14559        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14560        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14561        let publications_for_observer = Arc::clone(&publications);
14562        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14563            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14564        })));
14565        let source = root.join("lib.rs");
14566
14567        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
14568            .expect_err("borrow-only writable open must remain unavailable");
14569        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
14570        assert!(
14571            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
14572                .unwrap()
14573                .is_none()
14574        );
14575        assert!(
14576            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
14577                .unwrap()
14578                .is_none()
14579        );
14580        assert!(matches!(
14581            CallGraphStore::cold_build_with_lease(
14582                callgraph_dir.clone(),
14583                root.clone(),
14584                std::slice::from_ref(&source),
14585            ),
14586            Err(CallGraphStoreError::Unavailable(_))
14587        ));
14588        assert!(matches!(
14589            CallGraphStore::ensure_built_with_lease(
14590                callgraph_dir.clone(),
14591                root.clone(),
14592                std::slice::from_ref(&source),
14593            ),
14594            Err(CallGraphStoreError::Unavailable(_))
14595        ));
14596        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
14597            callgraph_dir.clone(),
14598            root.clone(),
14599            &[source],
14600            1,
14601        )
14602        .expect_err("borrow-only forced rebuild must remain unsatisfied");
14603        set_cold_build_swap_observer(None);
14604
14605        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
14606        assert_eq!(
14607            crate::root_cache::writer_lease_acquisition_count_for_test(
14608                crate::root_cache::RootCacheDomain::Callgraph,
14609                &project_key,
14610                &root,
14611            ),
14612            0
14613        );
14614        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
14615        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
14616    }
14617
14618    #[test]
14619    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
14620        let _git_env = crate::test_env::hermetic_git_env_guard();
14621        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
14622        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
14623        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
14624        let source = owner.join("lib.rs");
14625        let (store, _) = CallGraphStore::cold_build_with_lease(
14626            callgraph_dir.clone(),
14627            owner.clone(),
14628            std::slice::from_ref(&source),
14629        )
14630        .unwrap();
14631        let sqlite_path = store.sqlite_path().to_path_buf();
14632        drop(store);
14633
14634        let conn = Connection::open(&sqlite_path).unwrap();
14635        conn.execute(
14636            "UPDATE backend_file_state SET workspace_root = ?1",
14637            [worktree.display().to_string()],
14638        )
14639        .unwrap();
14640        drop(conn);
14641
14642        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14643        let publications_for_observer = Arc::clone(&publications);
14644        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
14645            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
14646        })));
14647        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
14648
14649        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14650            .unwrap()
14651            .expect("owner should purge the storm-era worktree root");
14652        drop(repaired);
14653        for _ in 0..3 {
14654            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
14655                .unwrap()
14656                .expect("linked worktree should borrow the owner generation");
14657            drop(borrower);
14658            assert!(
14659                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
14660                    .unwrap()
14661                    .is_none()
14662            );
14663            let owner_store =
14664                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
14665                    .unwrap()
14666                    .expect("owner generation should remain ready");
14667            drop(owner_store);
14668        }
14669        set_cold_build_swap_observer(None);
14670
14671        assert_eq!(
14672            publications.load(AtomicOrdering::SeqCst),
14673            1,
14674            "the owner performs one expected post-storm purge and alternation stays read-only"
14675        );
14676        assert_eq!(
14677            crate::root_cache::writer_lease_acquisition_count_for_test(
14678                crate::root_cache::RootCacheDomain::Callgraph,
14679                &project_key,
14680                &worktree,
14681            ),
14682            0
14683        );
14684    }
14685
14686    #[test]
14687    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
14688        let temp = tempdir().unwrap();
14689        let root = temp.path().join("owner");
14690        let other_root = temp.path().join("other");
14691        fs::create_dir_all(&root).unwrap();
14692        fs::create_dir_all(&other_root).unwrap();
14693        let source = root.join("lib.rs");
14694        fs::write(&source, "pub fn marker() {}\n").unwrap();
14695        let project_key = crate::search_index::artifact_cache_key(&root);
14696        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
14697        crate::root_cache::configure_artifact_access(&root, &project_key, false);
14698        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
14699        rebuild_cooldown_records()
14700            .lock()
14701            .unwrap_or_else(std::sync::PoisonError::into_inner)
14702            .remove(&cooldown_key);
14703        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
14704        let stale_epoch = epoch.current();
14705        epoch.next();
14706
14707        let failed = with_publish_epoch(epoch, stale_epoch, || {
14708            CallGraphStore::cold_build_with_lease(
14709                callgraph_dir.clone(),
14710                root.clone(),
14711                std::slice::from_ref(&source),
14712            )
14713        });
14714        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
14715        assert!(
14716            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14717                .is_none()
14718        );
14719
14720        let (store, _) = CallGraphStore::cold_build_with_lease(
14721            callgraph_dir.clone(),
14722            root.clone(),
14723            std::slice::from_ref(&source),
14724        )
14725        .unwrap();
14726        drop(store);
14727        assert!(
14728            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
14729                .is_none()
14730        );
14731
14732        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
14733        assert!(
14734            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
14735        );
14736    }
14737
14738    #[test]
14739    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
14740        let _guard = REFRESH_WORKER_TEST_LOCK
14741            .lock()
14742            .unwrap_or_else(std::sync::PoisonError::into_inner);
14743        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14744        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14745        let pending = pending_paths();
14746        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14747
14748        let lifecycle = SubcLifecycleAdmission::default();
14749        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14750        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14751        let ticket = CallgraphRefreshTicket::new(
14752            lifecycle,
14753            Arc::clone(&generation),
14754            7,
14755            publish_epoch.clone(),
14756            publish_epoch.current(),
14757        );
14758        // Supersede before the worker runs: the batch must defer, not commit.
14759        generation.store(8, std::sync::atomic::Ordering::SeqCst);
14760        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14761            .unwrap()
14762            .expect("ready store snapshot");
14763        let refresh_state = CallgraphRefreshState::new(
14764            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
14765            Arc::new(AtomicBool::new(true)),
14766        );
14767
14768        enqueue_callgraph_store_refresh_fenced_with_state(
14769            callgraph_dir,
14770            root.clone(),
14771            vec![source.clone()],
14772            Arc::clone(&pending),
14773            refresh_state,
14774            ticket,
14775        );
14776        assert!(flush_callgraph_store_refreshes_with_budget(
14777            Duration::from_secs(5)
14778        ));
14779        assert_eq!(
14780            callgraph_refresh_worker_test_counts(&root).0,
14781            0,
14782            "superseded batch must not reach refresh_files or self-replay"
14783        );
14784        assert!(
14785            pending.lock().contains(&source),
14786            "superseded batch must defer its paths to the pending sink"
14787        );
14788        clear_callgraph_refresh_worker_test_seam(&root);
14789    }
14790
14791    #[test]
14792    fn superseded_open_failure_defers_without_self_replay() {
14793        let _guard = REFRESH_WORKER_TEST_LOCK
14794            .lock()
14795            .unwrap_or_else(std::sync::PoisonError::into_inner);
14796        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14797        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14798        let pending = pending_paths();
14799        let installed = Arc::new(
14800            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
14801                .unwrap()
14802                .expect("ready store snapshot"),
14803        );
14804        let refresh_state = CallgraphRefreshState::new(
14805            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
14806            Arc::new(AtomicBool::new(true)),
14807        );
14808        assert!(!installed.is_legacy_fallback());
14809        assert!(installed.is_current());
14810        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14811        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14812        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
14813        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
14814
14815        let lifecycle = SubcLifecycleAdmission::default();
14816        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
14817        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14818        let ticket = CallgraphRefreshTicket::new(
14819            lifecycle,
14820            Arc::clone(&generation),
14821            7,
14822            publish_epoch.clone(),
14823            publish_epoch.current(),
14824        );
14825        enqueue_callgraph_store_refresh_fenced_with_state(
14826            callgraph_dir,
14827            root.clone(),
14828            vec![source.clone()],
14829            Arc::clone(&pending),
14830            refresh_state,
14831            ticket,
14832        );
14833        held_rx
14834            .recv_timeout(Duration::from_secs(12))
14835            .expect("refresh worker must hold after injected open failure");
14836
14837        // Mark the refresh request obsolete after the injected open failure,
14838        // then unblock the worker before its deferred retry can run.
14839        generation.store(8, std::sync::atomic::Ordering::SeqCst);
14840        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
14841        release_tx
14842            .send(())
14843            .expect("release superseded refresh worker");
14844        wait_for_refresh_worker_idle();
14845
14846        assert_eq!(
14847            callgraph_refresh_worker_test_counts(&root).0,
14848            1,
14849            "superseded open-failure batch must not self-replay"
14850        );
14851        assert_eq!(
14852            callgraph_refresh_worker_test_worker_calls(&root),
14853            1,
14854            "superseded open-failure batch must not create another worker call"
14855        );
14856        assert!(
14857            pending.lock().contains(&source),
14858            "superseded open-failure paths must remain in the pending sink"
14859        );
14860        let tree = installed
14861            .call_tree(Path::new("main.rs"), "entry", 1)
14862            .unwrap();
14863        assert_eq!(
14864            tree.children[0].name, "old_leaf",
14865            "superseded open-failure batch must not converge the store"
14866        );
14867        clear_callgraph_refresh_worker_test_seam(&root);
14868    }
14869
14870    #[test]
14871    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
14872        let _guard = REFRESH_WORKER_TEST_LOCK
14873            .lock()
14874            .unwrap_or_else(std::sync::PoisonError::into_inner);
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::ZERO, false);
14879
14880        let lifecycle = SubcLifecycleAdmission::default();
14881        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
14882        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14883        let expected_epoch = publish_epoch.current();
14884        let ticket = CallgraphRefreshTicket::new(
14885            lifecycle,
14886            generation,
14887            3,
14888            publish_epoch.clone(),
14889            expected_epoch,
14890        );
14891        // A cold build published a replacement generation after enqueue.
14892        publish_epoch.next();
14893
14894        enqueue_callgraph_store_refresh_fenced(
14895            callgraph_dir,
14896            root.clone(),
14897            vec![source.clone()],
14898            Arc::clone(&pending),
14899            ticket,
14900        );
14901        assert!(flush_callgraph_store_refreshes_with_budget(
14902            Duration::from_secs(5)
14903        ));
14904        assert_eq!(
14905            callgraph_refresh_worker_test_counts(&root).0,
14906            0,
14907            "epoch-superseded batch must not reach refresh_files"
14908        );
14909        assert!(
14910            pending.lock().contains(&source),
14911            "epoch-superseded batch must defer its paths to the pending sink"
14912        );
14913        clear_callgraph_refresh_worker_test_seam(&root);
14914    }
14915
14916    #[test]
14917    fn fenced_refresh_with_current_ticket_commits_normally() {
14918        let _guard = REFRESH_WORKER_TEST_LOCK
14919            .lock()
14920            .unwrap_or_else(std::sync::PoisonError::into_inner);
14921        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14922        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14923        let pending = pending_paths();
14924        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14925
14926        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
14927
14928        let lifecycle = SubcLifecycleAdmission::default();
14929        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
14930        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
14931        let ticket = CallgraphRefreshTicket::new(
14932            lifecycle,
14933            generation,
14934            5,
14935            publish_epoch.clone(),
14936            publish_epoch.current(),
14937        );
14938
14939        enqueue_callgraph_store_refresh_fenced(
14940            callgraph_dir.clone(),
14941            root.clone(),
14942            vec![source.clone()],
14943            Arc::clone(&pending),
14944            ticket,
14945        );
14946        assert!(flush_callgraph_store_refreshes_with_budget(
14947            Duration::from_secs(5)
14948        ));
14949        assert_eq!(
14950            callgraph_refresh_worker_test_counts(&root).0,
14951            1,
14952            "current ticket must run the refresh"
14953        );
14954        assert!(
14955            pending.lock().is_empty(),
14956            "committed batch must not defer paths"
14957        );
14958
14959        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
14960            .unwrap()
14961            .expect("published generation must remain readable");
14962        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
14963        assert_eq!(
14964            tree.children[0].name, "new_leaf",
14965            "fenced commit must actually persist the refreshed content"
14966        );
14967        clear_callgraph_refresh_worker_test_seam(&root);
14968    }
14969
14970    #[test]
14971    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
14972        let _guard = REFRESH_WORKER_TEST_LOCK
14973            .lock()
14974            .unwrap_or_else(std::sync::PoisonError::into_inner);
14975        // Generous pre-drain: the refresh worker is process-wide, so a prior
14976        // test's still-running batch (slow Windows CI) must fully settle
14977        // before this test enqueues, or its wait deadline absorbs the
14978        // leftover work. Idle workers return immediately.
14979        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14980        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
14981        let pending = pending_paths();
14982        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
14983
14984        enqueue_callgraph_store_refresh(
14985            callgraph_dir.clone(),
14986            root.clone(),
14987            vec![source.clone()],
14988            Arc::clone(&pending),
14989        );
14990        wait_for_refresh_calls(&root, 1);
14991        for _ in 0..3 {
14992            enqueue_callgraph_store_refresh(
14993                callgraph_dir.clone(),
14994                root.clone(),
14995                vec![source.clone()],
14996                Arc::clone(&pending),
14997            );
14998        }
14999
15000        assert!(flush_callgraph_store_refreshes_with_budget(
15001            Duration::from_secs(2)
15002        ));
15003        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
15004        assert!(pending.lock().is_empty());
15005        clear_callgraph_refresh_worker_test_seam(&root);
15006    }
15007
15008    #[test]
15009    fn queued_refresh_opens_generation_published_after_enqueue() {
15010        let _guard = REFRESH_WORKER_TEST_LOCK
15011            .lock()
15012            .unwrap_or_else(std::sync::PoisonError::into_inner);
15013        // Generous pre-drain: the refresh worker is process-wide, so a prior
15014        // test's still-running batch (slow Windows CI) must fully settle
15015        // before this test enqueues, or its wait deadline absorbs the
15016        // leftover work. Idle workers return immediately.
15017        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15018        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15019        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
15020        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
15021        let (active_held_rx, active_release_tx) =
15022            install_callgraph_refresh_worker_test_gate(active_root.clone());
15023        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
15024        enqueue_callgraph_store_refresh(
15025            active_dir,
15026            active_root.clone(),
15027            vec![active_source],
15028            pending_paths(),
15029        );
15030        active_held_rx
15031            .recv_timeout(Duration::from_secs(12))
15032            .expect("active refresh worker holds the queue");
15033
15034        fs::write(
15035            &target_source,
15036            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15037        )
15038        .unwrap();
15039        enqueue_callgraph_store_refresh(
15040            target_dir.clone(),
15041            target_root.clone(),
15042            vec![target_source.clone()],
15043            pending_paths(),
15044        );
15045        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
15046            target_dir.clone(),
15047            target_root.clone(),
15048            std::slice::from_ref(&target_source),
15049        )
15050        .unwrap();
15051        fs::write(
15052            &target_source,
15053            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15054        )
15055        .unwrap();
15056        drop(new_generation);
15057
15058        active_release_tx
15059            .send(())
15060            .expect("release active refresh worker");
15061        wait_for_refresh_calls(&target_root, 1);
15062        assert!(flush_callgraph_store_refreshes_with_budget(
15063            Duration::from_secs(12)
15064        ));
15065        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
15066            .unwrap()
15067            .expect("current callgraph generation");
15068        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15069        assert_eq!(tree.children[0].name, "worker_leaf");
15070        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
15071        clear_callgraph_refresh_worker_test_seam(&active_root);
15072        clear_callgraph_refresh_worker_test_seam(&target_root);
15073    }
15074
15075    #[test]
15076    fn refresh_failure_marks_files_stale() {
15077        let _guard = REFRESH_WORKER_TEST_LOCK
15078            .lock()
15079            .unwrap_or_else(std::sync::PoisonError::into_inner);
15080        // Generous pre-drain: the refresh worker is process-wide, so a prior
15081        // test's still-running batch (slow Windows CI) must fully settle
15082        // before this test enqueues, or its wait deadline absorbs the
15083        // leftover work. Idle workers return immediately.
15084        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15085        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15086        let pending = pending_paths();
15087        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
15088
15089        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
15090        assert!(flush_callgraph_store_refreshes_with_budget(
15091            Duration::from_secs(2)
15092        ));
15093
15094        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
15095        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
15096            .unwrap()
15097            .expect("ready callgraph store");
15098        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
15099        clear_callgraph_refresh_worker_test_seam(&root);
15100    }
15101
15102    #[test]
15103    fn idle_refresh_truncates_wal() {
15104        let _guard = REFRESH_WORKER_TEST_LOCK
15105            .lock()
15106            .unwrap_or_else(std::sync::PoisonError::into_inner);
15107        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15108        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15109        let generation = read_pointer(
15110            &callgraph_dir,
15111            &crate::search_index::artifact_cache_key(&root),
15112        )
15113        .expect("fixture publishes a generation");
15114        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15115        let pending = pending_paths();
15116        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15117
15118        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15119        enqueue_callgraph_store_refresh(
15120            callgraph_dir.clone(),
15121            root.clone(),
15122            vec![source.clone()],
15123            Arc::clone(&pending),
15124        );
15125        wait_for_refresh_calls(&root, 1);
15126        wait_for_refresh_worker_idle();
15127        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15128        while fs::metadata(&wal_path)
15129            .map(|metadata| metadata.len())
15130            .unwrap_or(0)
15131            != 0
15132        {
15133            assert!(
15134                Instant::now() < checkpoint_deadline,
15135                "idle checkpoint did not truncate WAL"
15136            );
15137            std::thread::sleep(Duration::from_millis(5));
15138        }
15139        assert_eq!(
15140            fs::metadata(&wal_path)
15141                .map(|metadata| metadata.len())
15142                .unwrap_or(0),
15143            0,
15144            "idle transition truncates the refresh WAL"
15145        );
15146
15147        clear_callgraph_refresh_worker_test_seam(&root);
15148    }
15149
15150    #[test]
15151    fn bounded_shutdown_defers_unprocessed_batches() {
15152        let _guard = REFRESH_WORKER_TEST_LOCK
15153            .lock()
15154            .unwrap_or_else(std::sync::PoisonError::into_inner);
15155        // Generous pre-drain: the refresh worker is process-wide, so a prior
15156        // test's still-running batch (slow Windows CI) must fully settle
15157        // before this test enqueues, or its wait deadline absorbs the
15158        // leftover work. Idle workers return immediately.
15159        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15160        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15161        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15162        let active_pending = pending_paths();
15163        let queued_pending = pending_paths();
15164        set_callgraph_refresh_worker_test_seam(
15165            active_root.clone(),
15166            Duration::from_millis(300),
15167            false,
15168        );
15169
15170        enqueue_callgraph_store_refresh(
15171            active_dir,
15172            active_root.clone(),
15173            vec![active_source.clone()],
15174            Arc::clone(&active_pending),
15175        );
15176        wait_for_refresh_calls(&active_root, 1);
15177        enqueue_callgraph_store_refresh(
15178            queued_dir,
15179            queued_root.clone(),
15180            vec![queued_source.clone()],
15181            Arc::clone(&queued_pending),
15182        );
15183
15184        assert!(!flush_callgraph_store_refreshes_with_budget(
15185            Duration::from_millis(20)
15186        ));
15187        assert!(active_pending.lock().contains(&active_source));
15188        assert!(queued_pending.lock().contains(&queued_source));
15189        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15190        clear_callgraph_refresh_worker_test_seam(&active_root);
15191    }
15192}
15193
15194#[cfg(test)]
15195mod cold_build_insert_tests {
15196    use super::*;
15197    use crate::imports::ImportBlock;
15198    use std::cell::Cell;
15199    use std::fs;
15200    use std::path::{Path, PathBuf};
15201    use tempfile::tempdir;
15202
15203    thread_local! {
15204        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15205        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15206        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15207    }
15208
15209    fn count_caller_traversal_selects(sql: &str) {
15210        let sql = sql.trim_start();
15211        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15212            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15213        }
15214        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15215            && sql.contains("e.target_file =")
15216        {
15217            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15218        }
15219        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15220            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15221        }
15222    }
15223
15224    #[test]
15225    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15226        let dir = tempdir().unwrap();
15227        let previous_root = dir.path().join("previous-root");
15228        let current_root = dir.path().join("current-root");
15229        fs::create_dir_all(&previous_root).unwrap();
15230        fs::create_dir_all(&current_root).unwrap();
15231        fs::remove_dir(&previous_root).unwrap();
15232        let mut conn = Connection::open_in_memory().unwrap();
15233        initialize_schema(&conn).unwrap();
15234        conn.execute(
15235            "INSERT INTO backend_file_state(
15236                backend, workspace_root, file_path, content_hash, status, updated_at
15237             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15238            params![previous_root.display().to_string()],
15239        )
15240        .unwrap();
15241
15242        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
15243
15244        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15245        assert_eq!(
15246            stored_workspace_roots(&conn).unwrap(),
15247            vec![previous_root.display().to_string()]
15248        );
15249    }
15250
15251    #[test]
15252    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15253        assert_eq!(
15254            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15255            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15256        );
15257    }
15258
15259    #[test]
15260    fn legacy_migration_completion_log_has_operator_fields() {
15261        assert_eq!(
15262            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15263            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15264        );
15265    }
15266
15267    fn write_generation_with_age(
15268        dir: &Path,
15269        project_key: &str,
15270        ordinal: u64,
15271        age: Duration,
15272    ) -> String {
15273        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15274        let path = dir.join(&generation);
15275        fs::write(&path, b"sqlite placeholder").unwrap();
15276        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15277        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15278        generation
15279    }
15280
15281    #[test]
15282    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15283        let dir = tempfile::tempdir().unwrap();
15284        let project_key = "project";
15285        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15286        let previous =
15287            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15288        let pinned =
15289            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15290        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15291
15292        gc_old_generations(dir.path(), project_key, &current);
15293
15294        assert!(dir.path().join(&previous).is_file());
15295        assert!(dir.path().join(&pinned).is_file());
15296
15297        drop(marker);
15298        gc_old_generations(dir.path(), project_key, &current);
15299
15300        assert!(dir.path().join(&previous).is_file());
15301        assert!(!dir.path().join(&pinned).exists());
15302    }
15303
15304    #[test]
15305    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15306        let dir = tempfile::tempdir().unwrap();
15307        let project_key = "project";
15308        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15309        let _previous =
15310            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15311        let pinned =
15312            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15313        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15314        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15315
15316        gc_old_generations(dir.path(), project_key, &current);
15317
15318        assert!(dir.path().join(&pinned).is_file());
15319    }
15320
15321    #[test]
15322    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15323        let dir = tempfile::tempdir().unwrap();
15324        let project_key = "project";
15325        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15326        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15327        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15328        let old = write_generation_with_age(
15329            dir.path(),
15330            project_key,
15331            200,
15332            expired + Duration::from_secs(60),
15333        );
15334        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15335
15336        gc_old_generations(dir.path(), project_key, &current);
15337
15338        assert!(dir.path().join(&current).is_file());
15339        assert!(dir.path().join(&previous).is_file());
15340        assert!(!dir.path().join(&old).exists());
15341    }
15342
15343    fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
15344        let cache_dir = callgraph_root.join(key);
15345        fs::create_dir_all(cache_dir.join("nested")).unwrap();
15346        fs::write(
15347            cache_dir.join("nested").join("payload.sqlite"),
15348            b"old cache payload",
15349        )
15350        .unwrap();
15351        age_callgraph_root_tree(&cache_dir);
15352        cache_dir
15353    }
15354
15355    fn age_callgraph_root_tree(path: &Path) {
15356        let old = SystemTime::now()
15357            .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
15358            .unwrap_or(UNIX_EPOCH);
15359        let entries = fs::read_dir(path)
15360            .unwrap()
15361            .collect::<std::io::Result<Vec<_>>>()
15362            .unwrap();
15363        for entry in entries {
15364            let child = entry.path();
15365            if entry.file_type().unwrap().is_dir() {
15366                age_callgraph_root_tree(&child);
15367            } else {
15368                filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
15369                    .unwrap();
15370            }
15371        }
15372        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
15373    }
15374
15375    #[test]
15376    fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
15377        reset_callgraph_root_sweep_cursor_for_test();
15378        let storage = tempdir().unwrap();
15379        let callgraph_root = storage.path().join("callgraph");
15380        let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
15381        let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
15382        let fresh = callgraph_root.join("d1c2b3a495867768");
15383        fs::create_dir_all(&fresh).unwrap();
15384        fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
15385        let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
15386
15387        let writer_lease = crate::fs_lock::try_acquire(
15388            &crate::root_cache::writer_lease_path(&leased),
15389            Duration::ZERO,
15390        )
15391        .unwrap();
15392        age_callgraph_root_tree(&leased);
15393        let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
15394        // Same-host marker protection is PID-authoritative, so this old mtime
15395        // proves the reader guard instead of accidentally relying on freshness.
15396        age_callgraph_root_tree(&marked);
15397
15398        let first = sweep_callgraph_root_dirs_with_limits(
15399            &callgraph_root,
15400            &HashSet::new(),
15401            &HashSet::new(),
15402            CALLGRAPH_ROOT_SWEEP_BUDGET,
15403            usize::MAX,
15404        );
15405
15406        assert_eq!(first.removed, 1);
15407        assert!(first.bytes > 0, "the reaped byte count must be reported");
15408        assert!(!dead.exists(), "an aged dead root must be reaped");
15409        assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
15410        assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
15411        assert_eq!(first.skipped_fresh, 1, "a recent root must win");
15412        assert!(leased.is_dir(), "the leased root must survive");
15413        assert!(marked.is_dir(), "the reader-marked root must survive");
15414        assert!(fresh.is_dir(), "the recent root must survive");
15415
15416        drop(writer_lease);
15417        drop(marker);
15418        // Mutation controls: removing each guard and aging each payload makes
15419        // every initially protected decoy eligible for the next pass.
15420        for cache_dir in [&leased, &marked, &fresh] {
15421            age_callgraph_root_tree(cache_dir);
15422        }
15423        let second = sweep_callgraph_root_dirs_with_limits(
15424            &callgraph_root,
15425            &HashSet::new(),
15426            &HashSet::new(),
15427            CALLGRAPH_ROOT_SWEEP_BUDGET,
15428            usize::MAX,
15429        );
15430
15431        assert_eq!(second.removed, 3);
15432        for cache_dir in [&leased, &marked, &fresh] {
15433            assert!(
15434                !cache_dir.exists(),
15435                "the decoy must be reaped after its guard or freshness changes"
15436            );
15437        }
15438        reset_callgraph_root_sweep_cursor_for_test();
15439    }
15440
15441    #[test]
15442    fn callgraph_root_sweep_resumes_after_entry_budget() {
15443        reset_callgraph_root_sweep_cursor_for_test();
15444        let storage = tempdir().unwrap();
15445        let callgraph_root = storage.path().join("callgraph");
15446        let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
15447        let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
15448        let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
15449
15450        let first_pass = sweep_callgraph_root_dirs_with_limits(
15451            &callgraph_root,
15452            &HashSet::new(),
15453            &HashSet::new(),
15454            CALLGRAPH_ROOT_SWEEP_BUDGET,
15455            1,
15456        );
15457        assert!(first_pass.budget_exhausted);
15458        assert_eq!(first_pass.scanned, 1);
15459        assert!(!first.exists());
15460        assert!(second.exists());
15461        assert!(third.exists());
15462
15463        let second_pass = sweep_callgraph_root_dirs_with_limits(
15464            &callgraph_root,
15465            &HashSet::new(),
15466            &HashSet::new(),
15467            CALLGRAPH_ROOT_SWEEP_BUDGET,
15468            1,
15469        );
15470        assert!(second_pass.budget_exhausted);
15471        assert!(!second.exists());
15472        assert!(third.exists());
15473
15474        let third_pass = sweep_callgraph_root_dirs_with_limits(
15475            &callgraph_root,
15476            &HashSet::new(),
15477            &HashSet::new(),
15478            CALLGRAPH_ROOT_SWEEP_BUDGET,
15479            1,
15480        );
15481        assert!(!third_pass.budget_exhausted);
15482        assert!(!third.exists());
15483        reset_callgraph_root_sweep_cursor_for_test();
15484    }
15485
15486    #[test]
15487    fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
15488        reset_callgraph_root_sweep_cursor_for_test();
15489        let storage = tempdir().unwrap();
15490        let callgraph_root = storage.path().join("callgraph");
15491        let key = "a1b2c3d4e5f60718";
15492        let cache_dir = callgraph_root.join(key);
15493        fs::create_dir_all(&cache_dir).unwrap();
15494        let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
15495        let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
15496        let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
15497        publish_pointer(&cache_dir, key, &current).unwrap();
15498        age_callgraph_root_tree(&cache_dir);
15499        let memo_keys = HashSet::from([key.to_string()]);
15500
15501        let summary = sweep_callgraph_root_dirs_with_limits(
15502            &callgraph_root,
15503            &memo_keys,
15504            &HashSet::new(),
15505            CALLGRAPH_ROOT_SWEEP_BUDGET,
15506            usize::MAX,
15507        );
15508
15509        assert_eq!(summary.generation_gc, 1);
15510        assert!(cache_dir.join(&current).is_file());
15511        assert!(cache_dir.join(&previous).is_file());
15512        assert!(
15513            !cache_dir.join(&obsolete).exists(),
15514            "the store-wide sweep must collect an inactive live root's obsolete generation"
15515        );
15516        reset_callgraph_root_sweep_cursor_for_test();
15517    }
15518
15519    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
15520        let path = dir.join(name);
15521        fs::write(&path, b"temp placeholder").unwrap();
15522        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15523        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15524        path
15525    }
15526
15527    #[test]
15528    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
15529        let dir = tempdir().unwrap();
15530        // One directory holds both an aged orphan (with its journal sidecar) and a
15531        // fresh temporary, so this proves the sweep SELECTS by age rather than
15532        // deleting everything in the directory.
15533        let aged = "project.g100.1.sqlite.tmp.1.200";
15534        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
15535        let fresh = "project.g300.1.sqlite.tmp.1.400";
15536        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
15537        write_build_temp_with_age(dir.path(), aged, aged_age);
15538        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
15539        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15540
15541        sweep_orphaned_build_temps(dir.path());
15542
15543        assert!(
15544            !dir.path().join(aged).exists(),
15545            "aged orphan must be removed"
15546        );
15547        assert!(
15548            !dir.path().join(aged_journal).exists(),
15549            "aged journal sidecar must be removed"
15550        );
15551        assert!(
15552            dir.path().join(fresh).is_file(),
15553            "fresh temporary must survive"
15554        );
15555    }
15556
15557    #[test]
15558    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
15559        let storage = tempdir().unwrap();
15560        let storage_root = storage.path();
15561        // The production shape: a legacy per-harness store whose root no longer
15562        // builds there — no `.current` pointer, no running build — so the per-root
15563        // cleanup never fires for it. A sibling root still building in the
15564        // root-keyed store triggers the store-wide sweep, which must reach into the
15565        // legacy directory and reclaim the orphan.
15566        let legacy_dir = storage_root.join("opencode").join("callgraph");
15567        fs::create_dir_all(&legacy_dir).unwrap();
15568        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
15569        write_build_temp_with_age(
15570            &legacy_dir,
15571            orphan,
15572            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15573        );
15574        assert!(
15575            !legacy_dir.join("deadbeef.current").exists(),
15576            "the dead root has no current pointer"
15577        );
15578
15579        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
15580        fs::create_dir_all(&root_keyed_dir).unwrap();
15581
15582        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
15583
15584        assert!(
15585            !legacy_dir.join(orphan).exists(),
15586            "legacy orphan must be reclaimed by the store-wide sweep"
15587        );
15588    }
15589
15590    #[test]
15591    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
15592        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
15593        // everything (min_age = 0) removes the fresh temporary that the real 24h
15594        // threshold spares in the test above. If a mutation to the age check leaves
15595        // the fresh file in place here, the predicate is no longer doing the
15596        // selection work the fresh-survives assertion relies on.
15597        let dir = tempdir().unwrap();
15598        let fresh = "project.g300.1.sqlite.tmp.1.400";
15599        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
15600
15601        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
15602
15603        assert!(
15604            !dir.path().join(fresh).exists(),
15605            "with the age predicate forced open, the fresh temporary is removed"
15606        );
15607    }
15608
15609    #[test]
15610    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
15611        let dir = tempdir().unwrap();
15612        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
15613        // to be swept, plus a live read marker, is generation GC's jurisdiction.
15614        // The orphan sweep must not intersect it.
15615        let generation = write_generation_with_age(
15616            dir.path(),
15617            "project",
15618            400,
15619            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
15620        );
15621        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15622
15623        sweep_orphaned_build_temps(dir.path());
15624
15625        assert!(
15626            dir.path().join(&generation).is_file(),
15627            "completed generation must survive the orphan sweep"
15628        );
15629        assert!(
15630            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
15631            "read marker must survive the orphan sweep"
15632        );
15633    }
15634
15635    #[test]
15636    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
15637        let dir = tempfile::tempdir().unwrap();
15638        let project_key = "project".to_string();
15639        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
15640        let sqlite_path = dir.path().join(&generation);
15641        fs::remove_file(&sqlite_path).unwrap();
15642        let conn = Connection::open(&sqlite_path).unwrap();
15643        let store = CallGraphStore::from_connection(
15644            dir.path().to_path_buf(),
15645            project_key,
15646            sqlite_path,
15647            dir.path().to_path_buf(),
15648            false,
15649            Some(generation.clone()),
15650            None,
15651            None,
15652            conn,
15653        );
15654
15655        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
15656        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
15657
15658        drop(marker);
15659        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
15660    }
15661
15662    #[test]
15663    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
15664        let dir = tempdir().expect("temp dir");
15665        let file = dir.path().join("main.ts");
15666        fs::write(&file, "export function main() {}\n").expect("write fixture");
15667        let store = CallGraphStore::open(
15668            dir.path().join(".store-readiness-cache"),
15669            dir.path().to_path_buf(),
15670        )
15671        .expect("open store");
15672        {
15673            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15674            conn.trace(Some(count_caller_traversal_selects));
15675        }
15676
15677        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15678        assert!(store.indexed_file_count().is_err());
15679        assert!(store.indexed_file_count().is_err());
15680        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15681
15682        store
15683            .cold_build(std::slice::from_ref(&file))
15684            .expect("cold build");
15685        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15686        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
15687        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
15688        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
15689
15690        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15691        conn.trace(None);
15692    }
15693
15694    #[test]
15695    fn direct_caller_frontier_chunks_sqlite_selects() {
15696        let dir = tempdir().expect("temp dir");
15697        let file = dir.path().join("main.ts");
15698        fs::write(
15699            &file,
15700            "export function caller() { target(); }\nexport function target() {}\n",
15701        )
15702        .expect("write fixture");
15703        let store = CallGraphStore::open(
15704            dir.path().join(".store-caller-frontier-query"),
15705            dir.path().to_path_buf(),
15706        )
15707        .expect("open store");
15708        store
15709            .cold_build(std::slice::from_ref(&file))
15710            .expect("cold build");
15711        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
15712        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
15713
15714        CALLER_QUERY_SELECTS.with(|count| count.set(0));
15715        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15716        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15717        {
15718            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15719            conn.trace(Some(count_caller_traversal_selects));
15720        }
15721        let callers = store
15722            .direct_callers_for_symbols(&targets)
15723            .expect("batched callers");
15724        {
15725            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15726            conn.trace(None);
15727        }
15728
15729        assert_eq!(callers.len(), 1_000);
15730        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
15731        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
15732        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
15733        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
15734    }
15735
15736    #[test]
15737    fn callers_depth_boundary_batches_sqlite_counts() {
15738        const CALLER_COUNT: usize = 1_000;
15739
15740        let dir = tempdir().expect("temp dir");
15741        let file = dir.path().join("main.ts");
15742        let mut source = String::from("export function sharedHotHelper() {}\n");
15743        for index in 0..CALLER_COUNT {
15744            source.push_str(&format!(
15745                "export function caller{index}() {{ sharedHotHelper(); }}\n"
15746            ));
15747        }
15748        fs::write(&file, source).expect("write fixture");
15749
15750        let store = CallGraphStore::open(
15751            dir.path().join(".store-callers-query-fanout"),
15752            dir.path().to_path_buf(),
15753        )
15754        .expect("open store");
15755        store
15756            .cold_build(std::slice::from_ref(&file))
15757            .expect("cold build");
15758
15759        CALLER_QUERY_SELECTS.with(|count| count.set(0));
15760        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
15761        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
15762        {
15763            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15764            conn.trace(Some(count_caller_traversal_selects));
15765        }
15766
15767        let started = Instant::now();
15768        let result = crate::commands::callgraph_store_adapter::callers_result(
15769            &store,
15770            Path::new("main.ts"),
15771            "sharedHotHelper",
15772            1,
15773            true,
15774        )
15775        .expect("callers result");
15776        let elapsed = started.elapsed();
15777
15778        {
15779            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
15780            conn.trace(None);
15781        }
15782        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
15783        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
15784        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
15785        eprintln!(
15786            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
15787            result.total_callers,
15788            caller_queries,
15789            boundary_queries,
15790            total_selects,
15791            elapsed.as_secs_f64() * 1_000.0
15792        );
15793
15794        assert_eq!(result.total_callers, CALLER_COUNT);
15795        assert_eq!(caller_queries, 1);
15796        assert_eq!(boundary_queries, 3);
15797        assert_eq!(total_selects, 9);
15798    }
15799
15800    #[test]
15801    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
15802        let dir = tempdir().expect("temp dir");
15803        let file = dir.path().join("main.ts");
15804        fs::write(
15805            &file,
15806            r#"export function topA() {
15807  root();
15808}
15809
15810export function topB() {
15811  root();
15812}
15813
15814export function root() {
15815  leaf();
15816  missing();
15817}
15818
15819export function leaf() {}
15820"#,
15821        )
15822        .expect("write fixture");
15823
15824        let store = CallGraphStore::open(
15825            dir.path().join(".store-depth-boundary-counts"),
15826            dir.path().to_path_buf(),
15827        )
15828        .expect("open store");
15829        store
15830            .cold_build(std::slice::from_ref(&file))
15831            .expect("cold build");
15832
15833        let root = store
15834            .node_for(Path::new("main.ts"), "root")
15835            .expect("root node");
15836        let leaf = store
15837            .node_for(Path::new("main.ts"), "leaf")
15838            .expect("leaf node");
15839
15840        let (full_forward_len, full_direct_len) = {
15841            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
15842            conn.execute(
15843                "INSERT INTO edges (
15844                    edge_id, ref_id, source_node, target_node, target_file,
15845                    target_symbol, kind, line, provenance
15846                 ) VALUES (
15847                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
15848                    ?2, ?3, 'call', 98, ?4
15849                 )",
15850                rusqlite::params![
15851                    &root.node_id,
15852                    &leaf.file,
15853                    &leaf.symbol,
15854                    PROVENANCE_TREESITTER
15855                ],
15856            )
15857            .expect("insert dangling forward edge");
15858            conn.execute(
15859                "INSERT INTO edges (
15860                    edge_id, ref_id, source_node, target_node, target_file,
15861                    target_symbol, kind, line, provenance
15862                 ) VALUES (
15863                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
15864                    ?1, ?2, ?3, 'call', 99, ?4
15865                 )",
15866                rusqlite::params![
15867                    &root.node_id,
15868                    &root.file,
15869                    &root.symbol,
15870                    PROVENANCE_TREESITTER
15871                ],
15872            )
15873            .expect("insert dangling direct-caller edge");
15874
15875            let full_forward_len = forward_calls_for_node(&conn, &root)
15876                .expect("full forward calls")
15877                .len();
15878            let counted_forward_len =
15879                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
15880            assert_eq!(
15881                counted_forward_len, full_forward_len,
15882                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
15883            );
15884
15885            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
15886                .expect("full direct callers");
15887            let full_direct_len = full_direct.len();
15888            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
15889                .expect("counted direct callers");
15890            assert_eq!(
15891                counted_direct_len, full_direct_len,
15892                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
15893            );
15894
15895            let distinct_direct_len = full_direct
15896                .iter()
15897                .map(|site| {
15898                    (
15899                        site.caller.file.clone(),
15900                        site.line,
15901                        site.target_file.clone(),
15902                        site.target_symbol.clone(),
15903                    )
15904                })
15905                .collect::<BTreeSet<_>>()
15906                .len();
15907            let batch_counts = direct_caller_counts_for_tuples(
15908                &conn,
15909                &[
15910                    (root.file.clone(), root.symbol.clone()),
15911                    (root.file.clone(), root.symbol.clone()),
15912                    (leaf.file.clone(), leaf.symbol.clone()),
15913                ],
15914            )
15915            .expect("batched direct-caller counts");
15916            assert_eq!(batch_counts.len(), 2);
15917            assert_eq!(
15918                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
15919                Some(&distinct_direct_len)
15920            );
15921
15922            (full_forward_len, full_direct_len)
15923        };
15924
15925        assert_eq!(
15926            full_forward_len, 2,
15927            "fixture root should have one resolved and one unresolved outgoing call"
15928        );
15929        assert_eq!(
15930            full_direct_len, 2,
15931            "fixture root should have two real direct callers"
15932        );
15933
15934        let tree = store
15935            .call_tree(Path::new("main.ts"), "root", 0)
15936            .expect("call tree");
15937        assert!(tree.depth_limited);
15938        assert_eq!(tree.children.len(), 0);
15939        assert_eq!(
15940            tree.truncated, full_forward_len,
15941            "call_tree depth boundary must report the full forward-call list length"
15942        );
15943
15944        let callers = store
15945            .callers_of(Path::new("main.ts"), "leaf", 0)
15946            .expect("callers");
15947        assert!(callers.depth_limited);
15948        assert_eq!(callers.callers.len(), 1);
15949        assert_eq!(callers.callers[0].caller.symbol, "root");
15950        assert_eq!(
15951            callers.truncated, full_direct_len,
15952            "callers depth boundary must report the full direct-caller list length"
15953        );
15954    }
15955
15956    #[test]
15957    fn source_freshness_matches_cache_collect_for_same_bytes() {
15958        let dir = tempdir().expect("temp dir");
15959        let path = dir.path().join("fixture.ts");
15960        let source = "export function main() { return helper(); }\n";
15961        fs::write(&path, source).expect("write fixture");
15962
15963        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
15964        let actual =
15965            collect_source_freshness(&path, source).expect("collect freshness from source");
15966
15967        assert_eq!(actual, expected);
15968    }
15969
15970    #[test]
15971    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
15972        let root = tempfile::tempdir().unwrap();
15973        let callgraph_dir = tempfile::tempdir().unwrap();
15974        let source_dir = root.path().join("src");
15975        std::fs::create_dir_all(&source_dir).unwrap();
15976        let source = source_dir.join("lib.rs");
15977        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
15978        let files = vec![source.clone()];
15979        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15980        let old_epoch = epoch.next();
15981        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
15982        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
15983        let old_epoch_flag = epoch.clone();
15984        let old_dir = callgraph_dir.path().to_path_buf();
15985        let old_root = root.path().to_path_buf();
15986        let old_files = files.clone();
15987        let old = std::thread::spawn(move || {
15988            set_cold_build_before_publish_observer(Some(Arc::new(move || {
15989                reached_tx.send(()).unwrap();
15990                release_rx.recv().unwrap();
15991            })));
15992            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
15993                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
15994            });
15995            set_cold_build_before_publish_observer(None);
15996            result
15997        });
15998        // Positive wait: the older build runs a real cold build (git probe +
15999        // SQLite schema init) before the barrier, which can exceed 5s on a
16000        // contended Windows CI runner. Only negative waits stay short.
16001        reached_rx
16002            .recv_timeout(Duration::from_secs(30))
16003            .expect("older build did not reach its publication barrier");
16004
16005        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
16006        let new_epoch = epoch.next();
16007        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
16008            CallGraphStore::cold_build_with_lease(
16009                callgraph_dir.path().to_path_buf(),
16010                root.path().to_path_buf(),
16011                &files,
16012            )
16013        })
16014        .expect("newer build should publish");
16015        drop(new_store);
16016
16017        release_tx.send(()).unwrap();
16018        assert!(matches!(
16019            old.join().unwrap(),
16020            Err(CallGraphStoreError::Superseded)
16021        ));
16022
16023        let current = CallGraphStore::open_readonly(
16024            callgraph_dir.path().to_path_buf(),
16025            root.path().to_path_buf(),
16026        )
16027        .unwrap()
16028        .expect("current callgraph generation");
16029        assert_eq!(
16030            current
16031                .nodes_matching("new_generation_marker")
16032                .unwrap()
16033                .len(),
16034            1
16035        );
16036        assert!(current
16037            .nodes_matching("old_generation_marker")
16038            .unwrap()
16039            .is_empty());
16040    }
16041
16042    #[test]
16043    fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
16044        let root = tempfile::tempdir().unwrap();
16045        let callgraph_dir = tempfile::tempdir().unwrap();
16046        let source = root.path().join("lib.rs");
16047        std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
16048        let files = vec![source];
16049        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16050        let old_epoch = epoch.next();
16051        let epoch_for_observer = epoch.clone();
16052        set_cold_build_before_publish_observer(Some(Arc::new(move || {
16053            epoch_for_observer.next();
16054        })));
16055        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16056            CallGraphStore::cold_build_with_lease_chunked(
16057                callgraph_dir.path().to_path_buf(),
16058                root.path().to_path_buf(),
16059                &files,
16060                1,
16061            )
16062        });
16063        set_cold_build_before_publish_observer(None);
16064        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16065
16066        let project_key = crate::search_index::artifact_cache_key(root.path());
16067        let staging = callgraph_dir
16068            .path()
16069            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16070        let staged = Connection::open(&staging).unwrap();
16071        assert_eq!(
16072            staged_build_phase(&staged).unwrap().as_deref(),
16073            Some("ready")
16074        );
16075        drop(staged);
16076
16077        let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16078        let extracted_for_observer = Arc::clone(&extracted);
16079        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16080            extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
16081        })));
16082        let successor_epoch = epoch.next();
16083        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16084            CallGraphStore::cold_build_with_lease_chunked(
16085                callgraph_dir.path().to_path_buf(),
16086                root.path().to_path_buf(),
16087                &files,
16088                1,
16089            )
16090        })
16091        .expect("completed same-corpus staging publishes without rebuilding");
16092        set_cold_build_extract_observer(None);
16093
16094        assert_eq!(stats.files, 1);
16095        assert_eq!(
16096            extracted.load(AtomicOrdering::SeqCst),
16097            0,
16098            "completed staging must not repeat extraction"
16099        );
16100        drop(store);
16101    }
16102
16103    #[test]
16104    fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16105        let root = tempfile::tempdir().unwrap();
16106        let callgraph_dir = tempfile::tempdir().unwrap();
16107        let files = ["a.rs", "b.rs", "c.rs"]
16108            .into_iter()
16109            .map(|name| {
16110                let path = root.path().join(name);
16111                std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16112                    .unwrap();
16113                path
16114            })
16115            .collect::<Vec<_>>();
16116        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16117        let old_epoch = epoch.next();
16118        let superseded = Arc::new(AtomicBool::new(false));
16119        let epoch_for_observer = epoch.clone();
16120        let superseded_for_observer = Arc::clone(&superseded);
16121        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16122            if stage == "extraction"
16123                && completed == 1
16124                && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16125            {
16126                epoch_for_observer.next();
16127            }
16128        })));
16129
16130        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16131            CallGraphStore::cold_build_with_lease_chunked(
16132                callgraph_dir.path().to_path_buf(),
16133                root.path().to_path_buf(),
16134                &files,
16135                1,
16136            )
16137        });
16138        set_cold_build_slice_observer(None);
16139        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16140        assert!(superseded.load(AtomicOrdering::SeqCst));
16141
16142        let project_key = crate::search_index::artifact_cache_key(root.path());
16143        let staging = callgraph_dir
16144            .path()
16145            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16146        assert!(staging.exists(), "supersession must retain durable staging");
16147        let staged = Connection::open(&staging).unwrap();
16148        assert_eq!(
16149            staged_build_phase(&staged).unwrap().as_deref(),
16150            Some("extracting")
16151        );
16152        assert_eq!(
16153            query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16154            1
16155        );
16156        drop(staged);
16157
16158        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16159        let extracted_for_observer = Arc::clone(&extracted);
16160        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16161            extracted_for_observer
16162                .lock()
16163                .unwrap()
16164                .extend(paths.iter().filter_map(|path| {
16165                    path.file_name()
16166                        .map(|name| name.to_string_lossy().into_owned())
16167                }));
16168        })));
16169        let successor_epoch = epoch.next();
16170        let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16171            CallGraphStore::cold_build_with_lease_chunked(
16172                callgraph_dir.path().to_path_buf(),
16173                root.path().to_path_buf(),
16174                &files,
16175                1,
16176            )
16177        })
16178        .expect("same-corpus successor resumes and publishes");
16179        set_cold_build_extract_observer(None);
16180
16181        assert_eq!(stats.files, 3);
16182        assert_eq!(
16183            *extracted.lock().unwrap(),
16184            vec!["b.rs".to_string(), "c.rs".to_string()],
16185            "the successor must not repeat the committed first slice"
16186        );
16187        drop(store);
16188        assert!(
16189            !staging.exists(),
16190            "published staging moves to its generation"
16191        );
16192    }
16193
16194    #[test]
16195    fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16196        let root = tempfile::tempdir().unwrap();
16197        let callgraph_dir = tempfile::tempdir().unwrap();
16198        let first = root.path().join("a.rs");
16199        let second = root.path().join("b.rs");
16200        std::fs::write(&first, "pub fn a() {}\n").unwrap();
16201        std::fs::write(&second, "pub fn b() {}\n").unwrap();
16202        let mut files = vec![first.clone(), second.clone()];
16203        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16204        let old_epoch = epoch.next();
16205        let advanced = Arc::new(AtomicBool::new(false));
16206        let epoch_for_observer = epoch.clone();
16207        let advanced_for_observer = Arc::clone(&advanced);
16208        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16209            if stage == "extraction"
16210                && completed == 1
16211                && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16212            {
16213                epoch_for_observer.next();
16214            }
16215        })));
16216        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16217            CallGraphStore::cold_build_with_lease_chunked(
16218                callgraph_dir.path().to_path_buf(),
16219                root.path().to_path_buf(),
16220                &files,
16221                1,
16222            )
16223        });
16224        set_cold_build_slice_observer(None);
16225        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16226
16227        std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16228        let third = root.path().join("c.rs");
16229        std::fs::write(&third, "pub fn c() {}\n").unwrap();
16230        files.push(third);
16231        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16232        let extracted_for_observer = Arc::clone(&extracted);
16233        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16234            extracted_for_observer
16235                .lock()
16236                .unwrap()
16237                .extend(paths.iter().filter_map(|path| {
16238                    path.file_name()
16239                        .map(|name| name.to_string_lossy().into_owned())
16240                }));
16241        })));
16242        let successor_epoch = epoch.next();
16243        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16244            CallGraphStore::cold_build_with_lease_chunked(
16245                callgraph_dir.path().to_path_buf(),
16246                root.path().to_path_buf(),
16247                &files,
16248                1,
16249            )
16250        })
16251        .expect("changed-corpus successor restarts and publishes");
16252        set_cold_build_extract_observer(None);
16253
16254        assert_eq!(stats.files, 3);
16255        assert_eq!(
16256            *extracted.lock().unwrap(),
16257            vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16258            "fingerprint mismatch must invalidate every old extraction slice"
16259        );
16260        drop(store);
16261    }
16262
16263    #[test]
16264    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16265        let dir = tempdir().expect("temp dir");
16266        let project_root = dir.path();
16267        let extract = fixture_extract(project_root);
16268        let resolved = fixture_resolved(&extract);
16269
16270        let reference = build_reference_connection(project_root, &extract, &resolved);
16271        let optimized = build_optimized_connection(project_root, &extract, &resolved);
16272
16273        for table in [
16274            "files",
16275            "nodes",
16276            "file_dependencies",
16277            "dispatch_hints",
16278            "refs",
16279            "edges",
16280        ] {
16281            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
16282            // the reference and optimized builds run sequentially and can straddle a
16283            // one-second tick under load, so it is legitimately allowed to differ.
16284            // This mirrors the existing exclusions of `backend_file_state.updated_at`
16285            // and the chunked-vs-unchunked sibling test. The check is for structural
16286            // row equivalence of the optimized bulk insert, not wall-clock equality.
16287            let excluded: &[&str] = if table == "files" {
16288                &["indexed_at"]
16289            } else {
16290                &[]
16291            };
16292            assert_eq!(
16293                table_rows_without(&reference, table, excluded),
16294                table_rows_without(&optimized, table, excluded),
16295                "table `{table}` rows must match apart from wall-clock columns"
16296            );
16297        }
16298        assert_eq!(
16299            backend_state_rows(&reference),
16300            backend_state_rows(&optimized),
16301            "backend freshness rows must match apart from updated_at"
16302        );
16303        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16304    }
16305
16306    #[test]
16307    fn cold_build_chunked_matches_unchunked_logical_rows() {
16308        let dir = tempdir().expect("temp dir");
16309        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16310        write_chunked_equivalence_fixture(&project_root);
16311        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16312        assert!(
16313            files.len() > 6,
16314            "fixture should be large enough to split into multiple chunks"
16315        );
16316
16317        let unchunked = CallGraphStore::open(
16318            project_root.join(".store-unchunked"),
16319            project_root.to_path_buf(),
16320        )
16321        .expect("open unchunked store");
16322        let unchunked_stats = unchunked
16323            .cold_build_chunked(&files, 0)
16324            .expect("unchunked cold build");
16325
16326        let chunked = CallGraphStore::open(
16327            project_root.join(".store-chunked"),
16328            project_root.to_path_buf(),
16329        )
16330        .expect("open chunked store");
16331        let chunked_stats = chunked
16332            .cold_build_chunked(&files, 3)
16333            .expect("chunked cold build");
16334
16335        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16336        assert_eq!(
16337            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16338            chunked.edge_snapshot().expect("chunked edge snapshot"),
16339            "public edge snapshots must match"
16340        );
16341
16342        let dispatch_edges = {
16343            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
16344            conn.query_row(
16345                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
16346                [],
16347                |row| row.get::<_, i64>(0),
16348            )
16349            .expect("count dispatch edges")
16350        };
16351        assert!(
16352            dispatch_edges > 0,
16353            "fixture must exercise method-dispatch edge insertion"
16354        );
16355
16356        for table in [
16357            "edges",
16358            "refs",
16359            "nodes",
16360            "file_dependencies",
16361            "dispatch_hints",
16362        ] {
16363            assert_eq!(
16364                graph_table_rows(&unchunked, table),
16365                graph_table_rows(&chunked, table),
16366                "chunked cold build must match unchunked rows for {table}"
16367            );
16368        }
16369        assert_eq!(
16370            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
16371            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
16372            "files rows must match apart from indexed_at"
16373        );
16374        assert_eq!(
16375            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
16376            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
16377            "backend freshness rows must match apart from updated_at"
16378        );
16379
16380        let published_dir = project_root.join(".store-published");
16381        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
16382            published_dir.clone(),
16383            project_root.to_path_buf(),
16384            &files,
16385            0,
16386        )
16387        .expect("published unchunked cold build");
16388        assert!(
16389            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
16390                .expect("needs_cold_build after publish"),
16391            "published store should be ready"
16392        );
16393        drop(_published);
16394        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
16395            published_dir,
16396            project_root.to_path_buf(),
16397            &files,
16398            3,
16399        )
16400        .expect("ensure with a different chunk size");
16401        assert!(
16402            rebuild_stats.is_none(),
16403            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
16404        );
16405    }
16406
16407    #[test]
16408    fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
16409        let dir = tempdir().expect("temp dir");
16410        let project_root = dir.path().join("project");
16411        fs::create_dir_all(&project_root).expect("create project root");
16412        let project_root = fs::canonicalize(project_root).expect("canonical project root");
16413        let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
16414        let resolve_window = 19;
16415
16416        callgraph::clear_workspace_package_cache();
16417        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16418        let uncached = CallGraphStore::open(
16419            dir.path().join("store-uncached"),
16420            project_root.to_path_buf(),
16421        )
16422        .expect("open uncached store");
16423        let uncached_stats = uncached
16424            .cold_build_chunked_with_resolution_memo_for_test(
16425                &files,
16426                7,
16427                resolve_window,
16428                &uncached_memo,
16429            )
16430            .expect("uncached comparison build");
16431        assert!(
16432            uncached_stats.refs > resolve_window * 2,
16433            "fixture must cross several staged reference windows"
16434        );
16435
16436        callgraph::clear_workspace_package_cache();
16437        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16438        let cached =
16439            CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
16440                .expect("open cached store");
16441        let cached_stats = cached
16442            .cold_build_chunked_with_resolution_memo_for_test(
16443                &files,
16444                7,
16445                resolve_window,
16446                &cached_memo,
16447            )
16448            .expect("cached build");
16449
16450        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
16451        for table in [
16452            "nodes",
16453            "refs",
16454            "file_dependencies",
16455            "edges",
16456            "dispatch_hints",
16457            "type_ref_names",
16458            "meta",
16459            "staging_file_inventory",
16460            "staging_ref_context",
16461        ] {
16462            assert_eq!(
16463                graph_table_rows(&uncached, table),
16464                graph_table_rows(&cached, table),
16465                "memoized and uncached cold builds must produce identical {table} rows"
16466            );
16467        }
16468        assert_eq!(
16469            graph_table_rows_without(&uncached, "files", &["indexed_at"]),
16470            graph_table_rows_without(&cached, "files", &["indexed_at"]),
16471            "files rows must match apart from indexed_at"
16472        );
16473        assert_eq!(
16474            graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
16475            graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
16476            "backend rows must match apart from updated_at"
16477        );
16478
16479        let cached_module_computations = cached_memo.module_computations_for_test();
16480        assert!(
16481            !cached_module_computations.is_empty(),
16482            "fixture must exercise module resolution"
16483        );
16484        assert!(
16485            cached_module_computations.values().all(|count| *count == 1),
16486            "each importing-directory/specifier pair must reach the filesystem once"
16487        );
16488        let uncached_module_computations = uncached_memo.module_computations_for_test();
16489        assert!(
16490            uncached_module_computations
16491                .values()
16492                .copied()
16493                .max()
16494                .unwrap_or_default()
16495                > 16,
16496            "mutation control: disabling the memo must recompute a hot module target"
16497        );
16498
16499        let cached_package_probes = cached_memo
16500            .json_probes_for_test()
16501            .into_iter()
16502            .filter(|(path, _)| {
16503                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16504            })
16505            .collect::<HashMap<_, _>>();
16506        assert!(
16507            !cached_package_probes.is_empty(),
16508            "fixture must exercise package.json lookup"
16509        );
16510        assert!(
16511            cached_package_probes.values().all(|count| *count == 1),
16512            "every package.json path must be probed at most once per cold build"
16513        );
16514        let uncached_package_probes = uncached_memo
16515            .json_probes_for_test()
16516            .into_iter()
16517            .filter(|(path, _)| {
16518                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
16519            })
16520            .collect::<HashMap<_, _>>();
16521        let cached_probe_total: usize = cached_package_probes.values().sum();
16522        let uncached_probe_total: usize = uncached_package_probes.values().sum();
16523        assert!(
16524            uncached_probe_total > cached_probe_total * 20,
16525            "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
16526        );
16527    }
16528
16529    #[test]
16530    fn rust_declared_module_memo_parses_each_declaring_file_once_and_preserves_edges() {
16531        let dir = tempdir().expect("temp dir");
16532        let project_root = dir.path().join("project");
16533        fs::create_dir_all(&project_root).expect("create project root");
16534        let project_root = fs::canonicalize(project_root).expect("canonical project root");
16535        let files = write_rust_declared_module_memo_fixture(&project_root, 10);
16536
16537        let negative_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16538        for _ in 0..3 {
16539            assert_eq!(
16540                rust_declared_module_target(
16541                    &project_root,
16542                    "src/lib.rs",
16543                    "undeclared",
16544                    &negative_memo,
16545                ),
16546                None
16547            );
16548        }
16549        assert_eq!(
16550            negative_memo
16551                .rust_declaration_parses_for_test()
16552                .get("src/lib.rs"),
16553            Some(&1),
16554            "an undeclared module must be retained as a file-level negative result"
16555        );
16556
16557        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16558        let uncached = CallGraphStore::open(
16559            dir.path().join("rust-store-uncached"),
16560            project_root.to_path_buf(),
16561        )
16562        .expect("open uncached Rust store");
16563        let uncached_stats = uncached
16564            .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &uncached_memo)
16565            .expect("uncached Rust build");
16566
16567        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
16568        let cached = CallGraphStore::open(
16569            dir.path().join("rust-store-cached"),
16570            project_root.to_path_buf(),
16571        )
16572        .expect("open cached Rust store");
16573        let cached_stats = cached
16574            .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &cached_memo)
16575            .expect("cached Rust build");
16576
16577        assert!(
16578            cached_stats.refs >= 60,
16579            "fixture must exercise repeated qualified and undeclared paths"
16580        );
16581        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
16582        assert_eq!(
16583            uncached.edge_snapshot().expect("uncached edge snapshot"),
16584            cached.edge_snapshot().expect("cached edge snapshot"),
16585            "memoized Rust declarations must preserve the public edge set"
16586        );
16587        assert_eq!(
16588            graph_table_rows(&uncached, "edges"),
16589            graph_table_rows(&cached, "edges"),
16590            "source, symbol, target, and provenance rows must be byte-identical"
16591        );
16592
16593        let expected_declaring_files = [
16594            "src/lib.rs",
16595            "src/module_0.rs",
16596            "src/module_1.rs",
16597            "src/module_2.rs",
16598            "src/module_3.rs",
16599            "src/module_4.rs",
16600        ]
16601        .into_iter()
16602        .map(str::to_string)
16603        .collect::<BTreeSet<_>>();
16604        let cached_parses = cached_memo.rust_declaration_parses_for_test();
16605        assert_eq!(
16606            cached_parses.keys().cloned().collect::<BTreeSet<_>>(),
16607            expected_declaring_files,
16608            "the fixture must traverse exactly its six distinct declaring files"
16609        );
16610        assert!(
16611            cached_parses.values().all(|count| *count == 1),
16612            "each declaring file must be parsed once per cold build: {cached_parses:?}"
16613        );
16614
16615        let uncached_parses = uncached_memo.rust_declaration_parses_for_test();
16616        let uncached_parse_total: usize = uncached_parses.values().sum();
16617        let cached_parse_total: usize = cached_parses.values().sum();
16618        println!(
16619            "RUST_DECLARATION_PARSE_COUNTS memo=off:{uncached_parse_total} memo=on:{cached_parse_total} distinct={}",
16620            cached_parses.len()
16621        );
16622        assert!(
16623            uncached_parse_total > 100,
16624            "mutation control: disabling memo insertion must repeat declaration parses; got {uncached_parse_total}"
16625        );
16626        let cached_missing_refs = cached
16627            .conn
16628            .lock()
16629            .expect("callgraph store mutex poisoned")
16630            .query_row(
16631                "SELECT COUNT(*) FROM refs
16632                 WHERE full_ref = 'crate::undeclared::missing' AND target_file IS NULL",
16633                [],
16634                |row| row.get::<_, usize>(0),
16635            )
16636            .expect("count unresolved negative refs");
16637        assert_eq!(
16638            cached_missing_refs, 10,
16639            "all negative-result references must remain unresolved without reparsing"
16640        );
16641    }
16642
16643    #[test]
16644    fn refresh_after_adding_rust_module_declaration_uses_fresh_snapshot() {
16645        let dir = tempdir().expect("temp dir");
16646        let project_root = dir.path().join("project");
16647        fs::create_dir_all(project_root.join("src/custom")).expect("create Rust fixture");
16648        fs::write(
16649            project_root.join("Cargo.toml"),
16650            "[package]\nname = \"refresh-declaration-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
16651        )
16652        .expect("write Rust manifest");
16653        let lib = project_root.join("src/lib.rs");
16654        let existing = project_root.join("src/existing.rs");
16655        let added = project_root.join("src/custom/added.rs");
16656        fs::write(
16657            &lib,
16658            "mod existing;\npub fn run() { crate::added::target(); }\n",
16659        )
16660        .expect("write initial lib");
16661        fs::write(&existing, "pub fn existing() {}\n").expect("write existing module");
16662        fs::write(&added, "pub fn target() {}\n").expect("write added module target");
16663        let project_root = fs::canonicalize(project_root).expect("canonical project root");
16664        let lib = project_root.join("src/lib.rs");
16665        let existing = project_root.join("src/existing.rs");
16666        let added = project_root.join("src/custom/added.rs");
16667
16668        let store = CallGraphStore::open(
16669            dir.path().join("refresh-declaration-store"),
16670            project_root.to_path_buf(),
16671        )
16672        .expect("open refresh store");
16673        store
16674            .cold_build(&[lib.clone(), existing.clone(), added])
16675            .expect("initial cold build");
16676        assert!(
16677            store
16678                .direct_callers_of(Path::new("src/custom/added.rs"), "target")
16679                .expect("initial callers")
16680                .is_empty(),
16681            "the custom-path module must be unresolved before its declaration exists"
16682        );
16683
16684        fs::write(&existing, "pub fn existing() { let _ = 1; }\n").expect("touch existing module");
16685        store
16686            .refresh_files(std::slice::from_ref(&existing))
16687            .expect("warm refresh declaration loading");
16688        fs::write(
16689            &lib,
16690            "mod existing;\n#[path = \"custom/added.rs\"]\nmod added;\npub fn run() { crate::added::target(); }\n",
16691        )
16692        .expect("add custom-path module declaration");
16693        store
16694            .refresh_files(std::slice::from_ref(&lib))
16695            .expect("refresh declaring file");
16696
16697        let callers = store
16698            .direct_callers_of(Path::new("src/custom/added.rs"), "target")
16699            .expect("refreshed callers");
16700        assert!(
16701            callers
16702                .iter()
16703                .any(|site| { site.caller.file == "src/lib.rs" && site.caller.symbol == "run" }),
16704            "a fresh refresh generation must resolve the newly declared module: {callers:#?}"
16705        );
16706    }
16707
16708    // Benchmark the cold resolver with and without memoization. The generated
16709    // workspace has hundreds of TypeScript files below a deep package-manifest
16710    // ladder and enough imported calls for filesystem resolution to dominate
16711    // the uncached run.
16712    #[test]
16713    #[ignore]
16714    fn bench_cold_build_resolution_memo() {
16715        let dir = tempdir().expect("temp dir");
16716        let project_root = dir.path().join("project");
16717        fs::create_dir_all(&project_root).expect("create benchmark root");
16718        let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
16719        let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
16720        assert!(
16721            files.len() > 250,
16722            "benchmark fixture must contain hundreds of files"
16723        );
16724
16725        for enabled in [false, true] {
16726            callgraph::clear_workspace_package_cache();
16727            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
16728            let store = CallGraphStore::open(
16729                dir.path().join(if enabled {
16730                    "store-cached"
16731                } else {
16732                    "store-uncached"
16733                }),
16734                project_root.to_path_buf(),
16735            )
16736            .expect("open benchmark store");
16737            let cpu_started = process_cpu_time();
16738            let wall_started = Instant::now();
16739            let stats = store
16740                .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
16741                .expect("benchmark cold build");
16742            let wall_ms = wall_started.elapsed().as_millis();
16743            let cpu_ms = process_cpu_time()
16744                .checked_sub(cpu_started)
16745                .unwrap_or_default()
16746                .as_millis();
16747            println!(
16748                "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
16749                if enabled { "on" } else { "off" },
16750                stats.files,
16751                stats.refs,
16752                stats.edges,
16753                wall_ms,
16754                cpu_ms
16755            );
16756        }
16757    }
16758
16759    #[test]
16760    #[ignore]
16761    fn bench_rust_declared_module_memo_real_corpus() {
16762        let project_root = fs::canonicalize(env!("CARGO_MANIFEST_DIR"))
16763            .expect("canonical agent-file-tools crate root");
16764        let files = [
16765            "src/main.rs",
16766            "src/cli/mod.rs",
16767            "src/cli/index.rs",
16768            "src/cli/sandbox_launch.rs",
16769            "src/cli/warmup.rs",
16770        ]
16771        .into_iter()
16772        .map(|path| project_root.join(path))
16773        .collect::<Vec<_>>();
16774        assert!(
16775            files.iter().all(|path| path.is_file()),
16776            "real-corpus benchmark sources must exist"
16777        );
16778        let dir = tempdir().expect("benchmark temp dir");
16779        let mut baseline_edges = None;
16780        let mut baseline_stats = None;
16781        let enabled_modes = match std::env::var("AFT_RUST_DECL_MEMO").as_deref() {
16782            Ok("off") => vec![false],
16783            Ok("on") => vec![true],
16784            _ => vec![false, true],
16785        };
16786
16787        for enabled in enabled_modes {
16788            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, true);
16789            let store = CallGraphStore::open(
16790                dir.path().join(if enabled {
16791                    "rust-real-cached"
16792                } else {
16793                    "rust-real-uncached"
16794                }),
16795                project_root.to_path_buf(),
16796            )
16797            .expect("open real-corpus store");
16798            let phase_times = Arc::new(Mutex::new((None, None)));
16799            let observer_times = Arc::clone(&phase_times);
16800            set_cold_build_phase_observer(Some(Arc::new(move |phase| {
16801                let mut times = observer_times.lock().expect("phase timing mutex poisoned");
16802                match phase {
16803                    "resolution" if times.0.is_none() => times.0 = Some(Instant::now()),
16804                    "publication" if times.1.is_none() => times.1 = Some(Instant::now()),
16805                    _ => {}
16806                }
16807            })));
16808            let build = store.cold_build_chunked_with_resolution_memo_for_test(
16809                &files,
16810                COLD_BUILD_EXTRACT_BATCH_FILES,
16811                COLD_BUILD_RESOLVE_WINDOW,
16812                &memo,
16813            );
16814            set_cold_build_phase_observer(None);
16815            let stats = build.expect("real-corpus cold build");
16816            let times = phase_times.lock().expect("phase timing mutex poisoned");
16817            let resolution_elapsed = times
16818                .1
16819                .expect("publication phase timestamp")
16820                .duration_since(times.0.expect("resolution phase timestamp"));
16821            drop(times);
16822            let edges = graph_table_rows(&store, "edges");
16823            if let Some(expected) = &baseline_edges {
16824                assert_eq!(
16825                    &edges, expected,
16826                    "real-corpus edge rows, including provenance, must be byte-identical"
16827                );
16828            } else {
16829                baseline_edges = Some(edges);
16830            }
16831            let stats_tuple = (stats.files, stats.nodes, stats.refs, stats.edges);
16832            if let Some(expected) = baseline_stats {
16833                assert_eq!(stats_tuple, expected, "real-corpus build counts must match");
16834            } else {
16835                baseline_stats = Some(stats_tuple);
16836            }
16837            let parses = memo.rust_declaration_parses_for_test();
16838            println!(
16839                "BENCH_RUST_DECLARED_MODULE_MEMO memo={} files={} refs={} edges={} resolution_wall_ms={} declaration_parses={} distinct_declaring_files={}",
16840                if enabled { "on" } else { "off" },
16841                stats.files,
16842                stats.refs,
16843                stats.edges,
16844                resolution_elapsed.as_millis(),
16845                parses.values().sum::<usize>(),
16846                parses.len()
16847            );
16848        }
16849    }
16850
16851    // Perf A/B bench (not a gate): measures cold_build wall time at a given
16852    // chunk size against a real repo. Driven by env so the same binary can A/B
16853    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
16854    // memory work. Run:
16855    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
16856    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
16857    #[test]
16858    #[ignore]
16859    fn bench_cold_build_chunk() {
16860        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
16861        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
16862            .expect("AFT_PERF_CHUNK")
16863            .parse()
16864            .expect("AFT_PERF_CHUNK must be a non-negative integer");
16865        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
16866        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16867        let dir = tempdir().expect("temp dir");
16868        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
16869            .expect("open store");
16870        let started = Instant::now();
16871        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
16872        let ms = started.elapsed().as_millis();
16873        println!(
16874            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
16875            stats.files, stats.nodes, stats.refs, stats.edges
16876        );
16877    }
16878
16879    #[test]
16880    fn persisted_workspace_reexport_selects_its_package_dependency() {
16881        let root = tempdir().expect("temp dir");
16882        let dependencies = BTreeSet::from([
16883            "packages/aft-bridge/src/index.ts".to_string(),
16884            "packages/opencode-plugin/src/types.ts".to_string(),
16885        ]);
16886        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
16887
16888        assert_eq!(
16889            stored_dependencies_for_module(
16890                root.path(),
16891                "packages/opencode-plugin/src/shared/bash-hints.ts",
16892                "@cortexkit/aft-bridge",
16893                &dependencies,
16894                &indexed_files,
16895            ),
16896            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
16897        );
16898    }
16899
16900    #[test]
16901    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
16902        let dir = tempdir().expect("temp dir");
16903        let project_root = dir.path();
16904        let files =
16905            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
16906        let index_path = project_root.join("src/index.ts");
16907
16908        let store = CallGraphStore::open(
16909            project_root.join(".store-incremental-barrel"),
16910            project_root.to_path_buf(),
16911        )
16912        .expect("open incremental store");
16913        store.cold_build(&files).expect("initial cold build");
16914
16915        {
16916            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16917            let tx = conn.transaction().expect("dependency transaction");
16918            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
16919                .expect("dependent refs for barrel");
16920            let selected_ref_ids = dependent_refs
16921                .iter()
16922                .map(|dependent_ref| dependent_ref.ref_id.clone())
16923                .collect::<BTreeSet<_>>();
16924            let mut threaded_ref_ids = BTreeSet::new();
16925            let mut threaded_by_caller = BTreeMap::new();
16926            record_dependent_refs(
16927                &mut threaded_ref_ids,
16928                &mut threaded_by_caller,
16929                dependent_refs,
16930            );
16931            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
16932                .expect("old per-ref caller lookup");
16933
16934            assert_eq!(threaded_ref_ids, selected_ref_ids);
16935            assert_eq!(threaded_by_caller, old_by_caller);
16936            for consumer in [
16937                "src/consumer_a.ts",
16938                "src/consumer_b.ts",
16939                "src/consumer_c.ts",
16940            ] {
16941                assert!(
16942                    threaded_by_caller.contains_key(consumer),
16943                    "barrel edit should select dependent refs from {consumer}"
16944                );
16945            }
16946        }
16947
16948        fs::write(
16949            &index_path,
16950            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
16951        )
16952        .expect("edit barrel");
16953        let stats = store
16954            .refresh_files(std::slice::from_ref(&index_path))
16955            .expect("incremental refresh");
16956        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
16957        assert!(
16958            stats.dependency_selected_refs > 0,
16959            "barrel surface edit should select dependent refs"
16960        );
16961
16962        let cold_store = CallGraphStore::open(
16963            project_root.join(".store-cold-barrel"),
16964            project_root.to_path_buf(),
16965        )
16966        .expect("open cold rebuild store");
16967        cold_store
16968            .cold_build(&files)
16969            .expect("comparison cold build");
16970
16971        for table in [
16972            "nodes",
16973            "refs",
16974            "file_dependencies",
16975            "edges",
16976            "dispatch_hints",
16977        ] {
16978            assert_eq!(
16979                graph_table_rows(&store, table),
16980                graph_table_rows(&cold_store, table),
16981                "incremental refresh {table} rows must match cold rebuild"
16982            );
16983        }
16984
16985        let consumer_path = project_root.join("src/consumer_a.ts");
16986        fs::write(
16987            &consumer_path,
16988            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
16989        )
16990        .expect("edit barrel consumer");
16991        store
16992            .refresh_files(std::slice::from_ref(&consumer_path))
16993            .expect("refresh consumer through unchanged barrel");
16994        cold_store
16995            .cold_build(&files)
16996            .expect("comparison cold rebuild after consumer refresh");
16997        for table in [
16998            "nodes",
16999            "refs",
17000            "file_dependencies",
17001            "edges",
17002            "dispatch_hints",
17003        ] {
17004            assert_eq!(
17005                graph_table_rows(&store, table),
17006                graph_table_rows(&cold_store, table),
17007                "refresh through a persisted barrel must preserve cold-build {table} rows"
17008            );
17009        }
17010    }
17011
17012    fn build_reference_connection(
17013        project_root: &Path,
17014        extract: &FileExtract,
17015        resolved: &ResolvedRef,
17016    ) -> Connection {
17017        let mut conn = Connection::open_in_memory().expect("open reference db");
17018        configure_build_connection(&conn).expect("configure reference db");
17019        initialize_schema(&conn).expect("initialize reference schema");
17020        {
17021            let tx = conn.transaction().expect("reference transaction");
17022            clear_tables(&tx).expect("reference clear");
17023            insert_meta(&tx).expect("reference meta");
17024            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
17025            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
17026            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17027                .expect("reference dispatch edges");
17028            assert_eq!(supplemental, 0);
17029            tx.commit().expect("reference commit");
17030        }
17031        conn
17032    }
17033
17034    fn build_optimized_connection(
17035        project_root: &Path,
17036        extract: &FileExtract,
17037        resolved: &ResolvedRef,
17038    ) -> Connection {
17039        let mut conn = Connection::open_in_memory().expect("open optimized db");
17040        configure_build_connection(&conn).expect("configure optimized db");
17041        initialize_schema(&conn).expect("initialize optimized schema");
17042        {
17043            let tx = conn.transaction().expect("optimized transaction");
17044            clear_tables(&tx).expect("optimized clear");
17045            insert_meta(&tx).expect("optimized meta");
17046            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
17047            {
17048                let workspace_root = project_root.display().to_string();
17049                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
17050                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
17051                    .expect("optimized file extract");
17052                insert_resolved_ref_prepared(&mut inserts, resolved)
17053                    .expect("optimized resolved ref");
17054            }
17055            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
17056            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17057                .expect("optimized dispatch edges");
17058            assert_eq!(supplemental, 0);
17059            tx.commit().expect("optimized commit");
17060        }
17061        conn
17062    }
17063
17064    fn fixture_extract(_project_root: &Path) -> FileExtract {
17065        let rel_path = "src/main.ts".to_string();
17066        let target_path = "src/helper.ts".to_string();
17067        let node = NodeRecord {
17068            id: "node-main".to_string(),
17069            file_path: rel_path.clone(),
17070            name: "main".to_string(),
17071            scoped_name: "main".to_string(),
17072            kind: "function".to_string(),
17073            range: Range {
17074                start_line: 0,
17075                start_col: 0,
17076                end_line: 0,
17077                end_col: 32,
17078            },
17079            range_ordinal: 0,
17080            signature: Some("export function main()".to_string()),
17081            exported: true,
17082            is_default_export: false,
17083            is_type_like: false,
17084            is_callgraph_entry_point: true,
17085        };
17086        let mut dependencies = BTreeSet::new();
17087        dependencies.insert(target_path.clone());
17088        let raw_ref = RawRef {
17089            ref_id: "ref-main-helper".to_string(),
17090            caller_node: Some(node.id.clone()),
17091            caller_symbol: Some(node.scoped_name.clone()),
17092            caller_file: rel_path.clone(),
17093            kind: "call".to_string(),
17094            short_name: Some("helper".to_string()),
17095            full_ref: Some("helper".to_string()),
17096            module_path: None,
17097            import_kind: None,
17098            local_name: Some("helper".to_string()),
17099            requested_name: Some("helper".to_string()),
17100            namespace_alias: None,
17101            wildcard: false,
17102            line: 1,
17103            byte_start: 24,
17104            byte_end: 32,
17105            dependencies,
17106        };
17107        FileExtract {
17108            rel_path,
17109            freshness: FileFreshness {
17110                mtime: UNIX_EPOCH + Duration::from_secs(123),
17111                size: 40,
17112                content_hash: cache_freshness::hash_bytes(b"fixture source"),
17113            },
17114            lang: LangId::TypeScript,
17115            data: FileCallData {
17116                calls_by_symbol: HashMap::new(),
17117                value_refs_by_symbol: HashMap::new(),
17118                exported_symbols: Vec::new(),
17119                symbol_metadata: HashMap::new(),
17120                default_export_symbol: None,
17121                import_block: ImportBlock::empty(),
17122                lang: LangId::TypeScript,
17123            },
17124            nodes: vec![node.clone()],
17125            raw_refs: vec![raw_ref],
17126            dispatch_hints: vec![DispatchHint {
17127                id: "dispatch-main-helper".to_string(),
17128                method_name: "helper".to_string(),
17129                caller_node: node.id,
17130                file: "src/main.ts".to_string(),
17131                line: 1,
17132                byte_start: 24,
17133                byte_end: 32,
17134            }],
17135            surface_fingerprint: "surface".to_string(),
17136        }
17137    }
17138
17139    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
17140        let raw = extract.raw_refs[0].clone();
17141        let mut dependencies = raw.dependencies.clone();
17142        dependencies.insert("src/helper.ts".to_string());
17143        ResolvedRef {
17144            edge: Some(EdgeRecord {
17145                edge_id: "edge-main-helper".to_string(),
17146                source_node: raw.caller_node.clone().expect("caller node"),
17147                target_node: Some("node-helper".to_string()),
17148                target_file: "src/helper.ts".to_string(),
17149                target_symbol: "helper".to_string(),
17150                kind: "call".to_string(),
17151                line: raw.line,
17152            }),
17153            raw,
17154            status: "resolved".to_string(),
17155            target_node: Some("node-helper".to_string()),
17156            target_file: Some("src/helper.ts".to_string()),
17157            target_symbol: Some("helper".to_string()),
17158            dependencies,
17159        }
17160    }
17161
17162    fn write_rust_declared_module_memo_fixture(
17163        project_root: &Path,
17164        calls_per_module: usize,
17165    ) -> Vec<PathBuf> {
17166        fs::create_dir_all(project_root.join("src")).expect("create Rust fixture root");
17167        fs::write(
17168            project_root.join("Cargo.toml"),
17169            "[package]\nname = \"rust-declaration-memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17170        )
17171        .expect("write Rust fixture manifest");
17172
17173        let modules = (0..5)
17174            .map(|index| format!("module_{index}"))
17175            .collect::<Vec<_>>();
17176        let mut lib_source = modules
17177            .iter()
17178            .map(|module| format!("pub mod {module};\n"))
17179            .collect::<String>();
17180        lib_source.push_str("\npub fn dispatch() {\n");
17181        for call in 0..calls_per_module {
17182            for (index, module) in modules.iter().enumerate() {
17183                lib_source.push_str(&format!(
17184                    "    crate::{module}::leaf::target_{index}(); // call {call}\n"
17185                ));
17186            }
17187            lib_source.push_str("    crate::undeclared::missing();\n");
17188        }
17189        lib_source.push_str("}\n");
17190        let lib = project_root.join("src/lib.rs");
17191        fs::write(&lib, lib_source).expect("write Rust fixture lib");
17192        let mut files = vec![lib];
17193
17194        for (index, module) in modules.iter().enumerate() {
17195            let declaring_file = project_root.join(format!("src/{module}.rs"));
17196            fs::write(&declaring_file, "pub mod leaf;\n").expect("write nested module declaration");
17197            let target_file = project_root.join(format!("src/{module}/leaf.rs"));
17198            fs::create_dir_all(target_file.parent().expect("nested module parent"))
17199                .expect("create nested module directory");
17200            fs::write(&target_file, format!("pub fn target_{index}() {{}}\n"))
17201                .expect("write nested module target");
17202            files.push(declaring_file);
17203            files.push(target_file);
17204        }
17205        files
17206    }
17207
17208    fn write_ts_resolution_memo_fixture(
17209        project_root: &Path,
17210        package_count: usize,
17211        files_per_package: usize,
17212        calls_per_file: usize,
17213    ) -> Vec<PathBuf> {
17214        fs::create_dir_all(project_root).expect("create memo fixture root");
17215        fs::write(
17216            project_root.join("package.json"),
17217            r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
17218        )
17219        .expect("write workspace package manifest");
17220        fs::write(
17221            project_root.join("tsconfig.json"),
17222            r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
17223        )
17224        .expect("write fixture tsconfig");
17225
17226        let shared_root = project_root.join("packages/shared");
17227        let shared_source = shared_root.join("src/index.ts");
17228        fs::create_dir_all(shared_source.parent().expect("shared source parent"))
17229            .expect("create shared package");
17230        fs::write(
17231            shared_root.join("package.json"),
17232            r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
17233        )
17234        .expect("write shared package manifest");
17235        fs::write(
17236            &shared_source,
17237            "export function shared(value: number) { return value + 1; }\n",
17238        )
17239        .expect("write shared source");
17240        let mut files = vec![shared_source];
17241
17242        for package in 0..package_count {
17243            let package_root = project_root.join(format!("packages/app-{package:02}"));
17244            fs::create_dir_all(&package_root).expect("create app package");
17245            fs::write(
17246                package_root.join("package.json"),
17247                format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
17248            )
17249            .expect("write app package manifest");
17250            let source_dir = package_root.join("src/features/deep/nested/leaf");
17251            fs::create_dir_all(&source_dir).expect("create deep app source dir");
17252
17253            for file in 0..files_per_package {
17254                let source_path = source_dir.join(format!("caller_{file:03}.ts"));
17255                let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
17256                for call in 0..calls_per_file {
17257                    source.push_str(&format!(
17258                        "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
17259                    ));
17260                }
17261                fs::write(&source_path, source).expect("write app source");
17262                files.push(source_path);
17263            }
17264        }
17265
17266        files
17267    }
17268
17269    #[cfg(unix)]
17270    fn process_cpu_time() -> Duration {
17271        let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
17272        let result =
17273            unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
17274        if result != 0 {
17275            return Duration::ZERO;
17276        }
17277        let value = unsafe { value.assume_init() };
17278        Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
17279    }
17280
17281    #[cfg(not(unix))]
17282    fn process_cpu_time() -> Duration {
17283        Duration::ZERO
17284    }
17285
17286    fn write_chunked_equivalence_fixture(project_root: &Path) {
17287        let ts_dir = project_root.join("ts");
17288        fs::create_dir_all(&ts_dir).expect("create ts dir");
17289        fs::write(
17290            ts_dir.join("leaf.ts"),
17291            "export function leaf(value: number) {\n  return value + 1;\n}\n",
17292        )
17293        .expect("write ts leaf");
17294        fs::write(
17295            ts_dir.join("mid.ts"),
17296            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
17297        )
17298        .expect("write ts mid");
17299        fs::write(
17300            ts_dir.join("entry.ts"),
17301            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
17302        )
17303        .expect("write ts entry");
17304        fs::write(
17305            ts_dir.join("worker.ts"),
17306            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
17307        )
17308        .expect("write ts worker");
17309        for idx in 0..4 {
17310            fs::write(
17311                ts_dir.join(format!("extra_{idx}.ts")),
17312                format!(
17313                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
17314                ),
17315            )
17316            .expect("write ts extra");
17317        }
17318
17319        let rust_dir = project_root.join("src");
17320        let commands_dir = rust_dir.join("commands");
17321        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
17322        fs::write(
17323            rust_dir.join("context.rs"),
17324            r#"pub struct AppContext;
17325
17326impl AppContext {
17327    pub fn callgraph_store_for_ops(&self) -> usize {
17328        1
17329    }
17330}
17331"#,
17332        )
17333        .expect("write rust context");
17334        fs::write(
17335            rust_dir.join("lib.rs"),
17336            "pub mod context;\npub mod commands;\n",
17337        )
17338        .expect("write rust lib");
17339        fs::write(
17340            commands_dir.join("mod.rs"),
17341            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
17342        )
17343        .expect("write rust commands mod");
17344        for name in ["callers", "impact", "trace_to"] {
17345            fs::write(
17346                commands_dir.join(format!("{name}.rs")),
17347                format!(
17348                    r#"use crate::context::AppContext;
17349
17350pub fn handle_{name}(ctx: &AppContext) -> usize {{
17351    ctx.callgraph_store_for_ops()
17352}}
17353"#
17354                ),
17355            )
17356            .expect("write rust command");
17357        }
17358    }
17359
17360    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
17361        let src_dir = project_root.join("src");
17362        fs::create_dir_all(&src_dir).expect("create src dir");
17363
17364        let target_path = src_dir.join("target.ts");
17365        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
17366            .expect("write target");
17367
17368        let index_path = src_dir.join("index.ts");
17369        fs::write(&index_path, barrel_source).expect("write barrel");
17370
17371        let mut files = vec![target_path, index_path];
17372        for (file_name, function_name) in [
17373            ("consumer_a.ts", "consumerA"),
17374            ("consumer_b.ts", "consumerB"),
17375            ("consumer_c.ts", "consumerC"),
17376        ] {
17377            let path = src_dir.join(file_name);
17378            fs::write(
17379                &path,
17380                format!(
17381                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
17382                ),
17383            )
17384            .expect("write consumer");
17385            files.push(path);
17386        }
17387        files
17388    }
17389
17390    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
17391        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
17392        table_rows(&conn, table)
17393    }
17394
17395    fn graph_table_rows_without(
17396        store: &CallGraphStore,
17397        table: &str,
17398        excluded_columns: &[&str],
17399    ) -> Vec<String> {
17400        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
17401        table_rows_without(&conn, table, excluded_columns)
17402    }
17403
17404    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
17405        table_rows_without(conn, table, &[])
17406    }
17407
17408    fn table_rows_without(
17409        conn: &Connection,
17410        table: &str,
17411        excluded_columns: &[&str],
17412    ) -> Vec<String> {
17413        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
17414        let columns: Vec<String> = conn
17415            .prepare(&format!("PRAGMA table_info({table})"))
17416            .expect("prepare table_info")
17417            .query_map([], |row| row.get::<_, String>(1))
17418            .expect("query table_info")
17419            .collect::<std::result::Result<Vec<String>, _>>()
17420            .expect("collect columns")
17421            .into_iter()
17422            .filter(|column| !excluded_columns.contains(column.as_str()))
17423            .collect();
17424        let sql = format!(
17425            "SELECT {} FROM {table} ORDER BY {}",
17426            columns.join(", "),
17427            columns.join(", ")
17428        );
17429        conn.prepare(&sql)
17430            .expect("prepare table rows")
17431            .query_map([], |row| row_to_strings(row, columns.len()))
17432            .expect("query table rows")
17433            .collect::<std::result::Result<_, _>>()
17434            .expect("collect table rows")
17435    }
17436
17437    fn assert_cold_build_stats_match_except_elapsed(
17438        expected: &ColdBuildStats,
17439        actual: &ColdBuildStats,
17440    ) {
17441        assert_eq!(actual.files, expected.files, "file counts must match");
17442        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
17443        assert_eq!(actual.refs, expected.refs, "ref counts must match");
17444        assert_eq!(actual.edges, expected.edges, "edge counts must match");
17445        assert_eq!(
17446            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
17447            expected
17448                .failed_files
17449                .iter()
17450                .cloned()
17451                .collect::<BTreeSet<_>>(),
17452            "failed file sets must match"
17453        );
17454    }
17455
17456    fn backend_state_rows(conn: &Connection) -> Vec<String> {
17457        conn.prepare(
17458            "SELECT backend, workspace_root, file_path, content_hash, status
17459             FROM backend_file_state
17460             ORDER BY backend, workspace_root, file_path, content_hash, status",
17461        )
17462        .expect("prepare backend rows")
17463        .query_map([], |row| row_to_strings(row, 5))
17464        .expect("query backend rows")
17465        .collect::<std::result::Result<_, _>>()
17466        .expect("collect backend rows")
17467    }
17468
17469    fn secondary_indexes(conn: &Connection) -> Vec<String> {
17470        let mut indexes = Vec::new();
17471        for table in [
17472            "files",
17473            "nodes",
17474            "refs",
17475            "file_dependencies",
17476            "edges",
17477            "dispatch_hints",
17478            "type_ref_names",
17479            "backend_file_state",
17480            "meta",
17481        ] {
17482            let sql = format!("PRAGMA index_list({table})");
17483            let mut stmt = conn.prepare(&sql).expect("prepare index list");
17484            let rows = stmt
17485                .query_map([], |row| row.get::<_, String>(1))
17486                .expect("query index list");
17487            for name in rows {
17488                let name = name.expect("index name");
17489                if name.starts_with("idx_") {
17490                    indexes.push(format!("{table}:{name}"));
17491                }
17492            }
17493        }
17494        indexes.sort();
17495        indexes
17496    }
17497
17498    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
17499        let mut values = Vec::with_capacity(len);
17500        for index in 0..len {
17501            let value = row.get_ref(index)?;
17502            values.push(match value {
17503                rusqlite::types::ValueRef::Null => "NULL".to_string(),
17504                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
17505                rusqlite::types::ValueRef::Real(value) => value.to_string(),
17506                rusqlite::types::ValueRef::Text(value) => {
17507                    String::from_utf8_lossy(value).into_owned()
17508                }
17509                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
17510            });
17511        }
17512        Ok(values.join("\u{1f}"))
17513    }
17514}
17515
17516#[cfg(test)]
17517mod rust_resolution_tests {
17518    use super::*;
17519    use crate::inspect::job::CallgraphSnapshot;
17520    use std::fs;
17521    use tempfile::tempdir;
17522
17523    #[test]
17524    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
17525        let dir = tempdir().expect("tempdir");
17526        let root = dir.path();
17527        write_rust_manifest(root, "scoped-alias-fixture");
17528        write_file(
17529            root,
17530            "src/lib.rs",
17531            r#"pub mod finalization_contract;
17532
17533pub fn run_alias() {
17534    use crate::finalization_contract as fc;
17535    fc::check_mason_contract();
17536}
17537"#,
17538        );
17539        write_file(
17540            root,
17541            "src/finalization_contract.rs",
17542            r#"pub fn check_mason_contract() {}
17543fn planted_dead() {}
17544"#,
17545        );
17546
17547        let (store, snapshot) = cold_build_twice(root);
17548        assert_direct_caller(
17549            &store,
17550            "src/finalization_contract.rs",
17551            "check_mason_contract",
17552            "src/lib.rs",
17553            "run_alias",
17554        );
17555        assert_projected_call(
17556            root,
17557            &snapshot,
17558            "src/finalization_contract.rs",
17559            "check_mason_contract",
17560        );
17561        assert_no_projected_call(
17562            root,
17563            &snapshot,
17564            "src/finalization_contract.rs",
17565            "planted_dead",
17566        );
17567        assert!(
17568            store
17569                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
17570                .expect("planted dead callers")
17571                .is_empty(),
17572            "planted-dead guard should stay without callers"
17573        );
17574    }
17575
17576    #[test]
17577    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
17578        let dir = tempdir().expect("tempdir");
17579        let root = dir.path();
17580        write_rust_manifest(root, "inline-module-fixture");
17581        write_file(
17582            root,
17583            "src/lib.rs",
17584            r#"mod work_graph { fn operations() {} }
17585mod manifest { fn operations() {} }
17586mod audit { fn operations() {} }
17587mod dispatch { fn operations() {} }
17588mod finalization { fn operations() {} }
17589
17590pub fn run_inline_operations() {
17591    work_graph::operations();
17592    manifest::operations();
17593    audit::operations();
17594    dispatch::operations();
17595    finalization::operations();
17596}
17597
17598fn planted_dead() {}
17599"#,
17600        );
17601
17602        let (store, snapshot) = cold_build_twice(root);
17603        for module in [
17604            "work_graph",
17605            "manifest",
17606            "audit",
17607            "dispatch",
17608            "finalization",
17609        ] {
17610            assert_direct_caller(
17611                &store,
17612                "src/lib.rs",
17613                &format!("{module}::operations"),
17614                "src/lib.rs",
17615                "run_inline_operations",
17616            );
17617        }
17618        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
17619        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17620    }
17621
17622    #[test]
17623    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
17624        let dir = tempdir().expect("tempdir");
17625        let root = dir.path();
17626        fs::write(
17627            root.join("Cargo.toml"),
17628            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
17629        )
17630        .expect("write workspace manifest");
17631        write_file(
17632            root,
17633            "crates/but-action/Cargo.toml",
17634            r#"[package]
17635name = "but-action"
17636version = "0.1.0"
17637edition = "2021"
17638"#,
17639        );
17640        write_file(
17641            root,
17642            "crates/but-action/src/lib.rs",
17643            "mod action;\npub use action::{list_actions};\n",
17644        );
17645        write_file(
17646            root,
17647            "crates/but-action/src/action.rs",
17648            "pub fn list_actions() {}\nfn planted_dead() {}\n",
17649        );
17650        write_file(
17651            root,
17652            "crates/app/Cargo.toml",
17653            r#"[package]
17654name = "app"
17655version = "0.1.0"
17656edition = "2021"
17657"#,
17658        );
17659        write_file(
17660            root,
17661            "crates/app/src/lib.rs",
17662            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
17663        );
17664
17665        let (store, snapshot) = cold_build_twice(root);
17666        assert_direct_caller(
17667            &store,
17668            "crates/but-action/src/action.rs",
17669            "list_actions",
17670            "crates/app/src/lib.rs",
17671            "run_actions",
17672        );
17673        assert!(
17674            store
17675                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
17676                .expect("lib reexport callers")
17677                .is_empty(),
17678            "call should target the reexported source function, not lib.rs"
17679        );
17680        assert_projected_call(
17681            root,
17682            &snapshot,
17683            "crates/but-action/src/action.rs",
17684            "list_actions",
17685        );
17686        assert_no_projected_call(
17687            root,
17688            &snapshot,
17689            "crates/but-action/src/action.rs",
17690            "planted_dead",
17691        );
17692    }
17693
17694    #[test]
17695    fn rust_cfg_attributed_module_resolves_outgoing_calls() {
17696        let dir = tempdir().expect("tempdir");
17697        let root = dir.path();
17698        write_rust_manifest(root, "cfg-module-outgoing-fixture");
17699        write_file(
17700            root,
17701            "src/lib.rs",
17702            "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
17703        );
17704        for module in ["conformance", "ordinary"] {
17705            write_file(
17706                root,
17707                &format!("src/{module}.rs"),
17708                "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n    local_target();\n    project_range();\n}\n",
17709            );
17710        }
17711
17712        let (store, _) = cold_build_twice(root);
17713        for module in ["conformance", "ordinary"] {
17714            assert_direct_caller(
17715                &store,
17716                &format!("src/{module}.rs"),
17717                "local_target",
17718                &format!("src/{module}.rs"),
17719                "run",
17720            );
17721            assert_direct_caller(
17722                &store,
17723                "src/lib.rs",
17724                "project_range",
17725                &format!("src/{module}.rs"),
17726                "run",
17727            );
17728        }
17729    }
17730
17731    #[test]
17732    fn rust_registered_modules_preserve_import_alias_resolution() {
17733        let dir = tempdir().expect("tempdir");
17734        let root = dir.path();
17735        write_rust_manifest(root, "registered-module-import-control");
17736        write_file(
17737            root,
17738            "src/main.rs",
17739            "mod commands;\nmod db;\nfn main() {}\n",
17740        );
17741        write_file(
17742            root,
17743            "src/commands.rs",
17744            "use crate::db;\n\npub fn run() {\n    db::helper();\n}\n",
17745        );
17746        write_file(root, "src/db.rs", "pub fn helper() {}\n");
17747
17748        let main_extract =
17749            build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
17750        let commands_extract =
17751            build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
17752        let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
17753        let files = [&main_extract, &commands_extract, &db_extract]
17754            .into_iter()
17755            .map(|extract| {
17756                (
17757                    extract.rel_path.clone(),
17758                    DbFileIndex::from_extract(root, extract),
17759                )
17760            })
17761            .collect::<HashMap<_, _>>();
17762        let caller_data = [&main_extract, &commands_extract, &db_extract]
17763            .into_iter()
17764            .map(|extract| (extract.rel_path.clone(), &extract.data))
17765            .collect::<HashMap<_, _>>();
17766        let index = ProjectIndex::from_parts(
17767            root,
17768            files,
17769            caller_data,
17770            WorkspaceCratePrefixCache::default(),
17771        );
17772        assert_eq!(
17773            index.module_parent("src/commands.rs"),
17774            Some(("src/main.rs".to_string(), "commands".to_string()))
17775        );
17776        assert_eq!(
17777            index.module_target("src/main.rs", "db").as_deref(),
17778            Some("src/db.rs")
17779        );
17780        let call = commands_extract
17781            .raw_refs
17782            .iter()
17783            .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
17784            .expect("db helper call")
17785            .clone();
17786        let resolved = resolve_ref(call, &index).expect("resolve db helper");
17787        assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
17788        assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
17789
17790        let (store, _) = cold_build_twice(root);
17791        assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
17792    }
17793
17794    #[test]
17795    fn rust_path_attributed_module_uses_declared_logical_parent() {
17796        let dir = tempdir().expect("tempdir");
17797        let root = dir.path();
17798        write_rust_manifest(root, "path-module-outgoing-fixture");
17799        write_file(
17800            root,
17801            "src/lib.rs",
17802            "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
17803        );
17804        write_file(
17805            root,
17806            "src/alternate/custom.rs",
17807            "pub fn run() {\n    super::project_range();\n}\n",
17808        );
17809
17810        let (store, _) = cold_build_twice(root);
17811        assert_direct_caller(
17812            &store,
17813            "src/lib.rs",
17814            "project_range",
17815            "src/alternate/custom.rs",
17816            "run",
17817        );
17818    }
17819
17820    #[test]
17821    fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
17822        let dir = tempdir().expect("tempdir");
17823        let root = dir.path();
17824        write_rust_manifest(root, "same-file-test-module-fixture");
17825        write_file(
17826            root,
17827            "src/lib.rs",
17828            r#"pub struct Index(u32);
17829
17830impl Index {
17831    pub fn shares_index_with(&self, other: &Self) -> bool {
17832        self.0 == other.0
17833    }
17834}
17835
17836#[cfg(test)]
17837mod tests {
17838    use super::Index;
17839
17840    #[test]
17841    fn compares_indexes() {
17842        let before = Index(1);
17843        let after = Index(1);
17844        assert!(before.shares_index_with(&after));
17845    }
17846}
17847"#,
17848        );
17849
17850        let (store, snapshot) = cold_build_twice(root);
17851        assert_direct_caller(
17852            &store,
17853            "src/lib.rs",
17854            "Index::shares_index_with",
17855            "src/lib.rs",
17856            "tests::compares_indexes",
17857        );
17858        assert!(
17859            snapshot.outbound_calls.iter().any(|call| {
17860                call.caller_symbol == "compares_indexes"
17861                    && call.line == 17
17862                    && call.target.starts_with(&format!(
17863                        "shares_index_with{}before.shares_index_with",
17864                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
17865                    ))
17866            }),
17867            "expected projected macro receiver call; calls: {:#?}",
17868            snapshot.outbound_calls
17869        );
17870    }
17871
17872    #[test]
17873    fn rust_generic_self_turbofish_method_dispatch_resolves() {
17874        let dir = tempdir().expect("tempdir");
17875        let root = dir.path();
17876        write_rust_manifest(root, "generic-self-fixture");
17877        write_file(
17878            root,
17879            "src/lib.rs",
17880            r#"pub struct Matcher;
17881
17882impl Matcher {
17883    pub fn run(&self) -> bool {
17884        self.fuzzy_match_optimal::<usize>("needle")
17885    }
17886
17887    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
17888        let _ = std::marker::PhantomData::<T>;
17889        true
17890    }
17891
17892    fn planted_dead(&self) {}
17893}
17894
17895pub fn entry() -> bool {
17896    let matcher = Matcher;
17897    matcher.run()
17898}
17899"#,
17900        );
17901
17902        let (store, snapshot) = cold_build_twice(root);
17903        assert_direct_caller(
17904            &store,
17905            "src/lib.rs",
17906            "Matcher::fuzzy_match_optimal",
17907            "src/lib.rs",
17908            "Matcher::run",
17909        );
17910        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
17911        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
17912    }
17913
17914    #[test]
17915    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
17916        let dir = tempdir().expect("tempdir");
17917        let root = dir.path();
17918        write_rust_manifest(root, "manifest-operations-fixture");
17919        write_file(
17920            root,
17921            "src/main.rs",
17922            r#"mod dispatch;
17923use dispatch::{manifest_operations};
17924
17925fn main() {
17926    manifest_operations();
17927}
17928"#,
17929        );
17930        write_file(
17931            root,
17932            "src/dispatch.rs",
17933            r#"mod work_graph { fn operations() {} }
17934mod manifest { fn operations() {} }
17935mod audit { fn operations() {} }
17936mod descriptor { fn operations() {} }
17937mod writer { fn operations() {} }
17938
17939pub fn manifest_operations() {
17940    manifest::operations();
17941}
17942
17943pub fn work_graph_operations() {
17944    work_graph::operations();
17945}
17946
17947pub fn audit_operations() {
17948    audit::operations();
17949}
17950
17951pub fn descriptor_operations() {
17952    descriptor::operations();
17953}
17954
17955pub fn writer_operations() {
17956    writer::operations();
17957}
17958
17959fn planted_dead() {}
17960"#,
17961        );
17962
17963        let (store, snapshot) = cold_build_twice(root);
17964        assert_direct_caller(
17965            &store,
17966            "src/dispatch.rs",
17967            "manifest_operations",
17968            "src/main.rs",
17969            "main",
17970        );
17971        assert_direct_caller(
17972            &store,
17973            "src/dispatch.rs",
17974            "manifest::operations",
17975            "src/dispatch.rs",
17976            "manifest_operations",
17977        );
17978        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
17979        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
17980        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
17981    }
17982
17983    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
17984        let files = rust_files(root);
17985        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
17986            .expect("open first store");
17987        first.cold_build(&files).expect("first cold build");
17988        let first_snapshot =
17989            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
17990
17991        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
17992            .expect("open second store");
17993        second.cold_build(&files).expect("second cold build");
17994        let second_snapshot =
17995            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
17996
17997        assert_eq!(
17998            projection_rows(&first_snapshot),
17999            projection_rows(&second_snapshot),
18000            "cold-build projection should be deterministic"
18001        );
18002        (first, first_snapshot)
18003    }
18004
18005    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
18006        let mut rows = Vec::new();
18007        for export in &snapshot.exported_symbols {
18008            rows.push(format!(
18009                "export\t{}\t{}\t{}\t{}",
18010                export.file.display(),
18011                export.symbol,
18012                export.kind,
18013                export.line
18014            ));
18015        }
18016        for call in &snapshot.outbound_calls {
18017            rows.push(format!(
18018                "call\t{}\t{}\t{}\t{}\t{}",
18019                call.caller_file.display(),
18020                call.caller_symbol,
18021                call.target,
18022                call.line,
18023                call.provenance
18024            ));
18025        }
18026        for file in &snapshot.entry_points {
18027            rows.push(format!("entry_file\t{}", file.display()));
18028        }
18029        for (file, symbols) in &snapshot.entry_point_symbols {
18030            for symbol in symbols {
18031                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
18032            }
18033        }
18034        rows.sort();
18035        rows
18036    }
18037
18038    fn assert_direct_caller(
18039        store: &CallGraphStore,
18040        target_rel: &str,
18041        target_symbol: &str,
18042        caller_rel: &str,
18043        caller_symbol: &str,
18044    ) {
18045        let callers = store
18046            .direct_callers_of(Path::new(target_rel), target_symbol)
18047            .unwrap_or_else(|error| {
18048                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
18049            });
18050        assert!(
18051            callers.iter().any(|site| {
18052                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
18053            }),
18054            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
18055        );
18056    }
18057
18058    fn assert_projected_call(
18059        root: &Path,
18060        snapshot: &CallgraphSnapshot,
18061        target_rel: &str,
18062        symbol: &str,
18063    ) {
18064        let target = projected_target(root, target_rel, symbol);
18065        assert!(
18066            snapshot.outbound_calls.iter().any(|call| {
18067                call.target == target
18068                    || call.target.starts_with(&format!(
18069                        "{target}{}",
18070                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18071                    ))
18072            }),
18073            "expected projected call to {target}; calls: {:#?}",
18074            snapshot.outbound_calls
18075        );
18076    }
18077
18078    fn assert_no_projected_call(
18079        root: &Path,
18080        snapshot: &CallgraphSnapshot,
18081        target_rel: &str,
18082        symbol: &str,
18083    ) {
18084        let target = projected_target(root, target_rel, symbol);
18085        assert!(
18086            snapshot.outbound_calls.iter().all(|call| {
18087                call.target != target
18088                    && !call.target.starts_with(&format!(
18089                        "{target}{}",
18090                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18091                    ))
18092            }),
18093            "did not expect projected call to {target}; calls: {:#?}",
18094            snapshot.outbound_calls
18095        );
18096    }
18097
18098    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
18099        // Projection targets carry the normalized (verbatim-stripped)
18100        // canonical form; bare fs::canonicalize diverges on Windows.
18101        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
18102        format!("{}::{symbol}", path.display())
18103    }
18104
18105    fn write_rust_manifest(root: &Path, name: &str) {
18106        write_file(
18107            root,
18108            "Cargo.toml",
18109            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
18110        );
18111    }
18112
18113    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
18114        let path = root.join(rel_path);
18115        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
18116        fs::write(&path, source).expect("write fixture file");
18117        path
18118    }
18119
18120    fn rust_files(root: &Path) -> Vec<PathBuf> {
18121        let mut files = Vec::new();
18122        collect_rust_files(root, &mut files);
18123        files.sort();
18124        files
18125    }
18126
18127    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
18128        for entry in fs::read_dir(dir).expect("read fixture dir") {
18129            let entry = entry.expect("read fixture entry");
18130            let path = entry.path();
18131            if path.is_dir() {
18132                let name = path
18133                    .file_name()
18134                    .and_then(|name| name.to_str())
18135                    .unwrap_or("");
18136                if !name.starts_with(".store") {
18137                    collect_rust_files(&path, files);
18138                }
18139            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
18140                files.push(path);
18141            }
18142        }
18143    }
18144}
18145
18146#[cfg(test)]
18147mod build_pool_tests {
18148    use super::build_pool_size;
18149
18150    #[test]
18151    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
18152        let size = build_pool_size();
18153        // Never zero, never the full core count, never above the 8 cap — this is
18154        // the starvation guard for the cold-build's all-cores tree-sitter pass.
18155        assert!(size >= 1, "pool size must be at least 1");
18156        assert!(size <= 8, "pool size must be capped at 8, got {size}");
18157
18158        let cores = std::thread::available_parallelism()
18159            .map(|p| p.get())
18160            .unwrap_or(1);
18161        let expected = cores.div_ceil(2).clamp(1, 8);
18162        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
18163    }
18164}
18165
18166#[cfg(test)]
18167mod reexport_resolution_tests {
18168    use super::*;
18169
18170    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
18171        ProjectIndex {
18172            project_root: PathBuf::from("/fixture"),
18173            files: files.into_iter().collect(),
18174            caller_data: HashMap::new(),
18175            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
18176        }
18177    }
18178
18179    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
18180        DbFileIndex {
18181            lang: None,
18182            exports: HashSet::new(),
18183            default_export: None,
18184            export_aliases: HashMap::new(),
18185            node_by_scoped: HashMap::new(),
18186            node_by_bare: HashMap::new(),
18187            node_kind_by_id: HashMap::new(),
18188            module_targets: HashMap::new(),
18189            declared_module_targets: HashMap::new(),
18190            reexports: reexport_targets
18191                .iter()
18192                .map(|target| ReexportIndex {
18193                    target_file: Some((*target).to_string()),
18194                    named: HashMap::new(),
18195                    wildcard: true,
18196                })
18197                .collect(),
18198        }
18199    }
18200
18201    /// A dense wildcard re-export cycle (barrel files re-exporting each
18202    /// other) must resolve in O(files), not O(branching^depth). Without the
18203    /// resolver's memoization, resolving a MISSING symbol through this
18204    /// 12-file complete digraph explores ~11^16 paths and this test never
18205    /// finishes: the depth cap bounds path length, not path count, and one
18206    /// such resolution can pin a worker thread at 100% CPU indefinitely.
18207    #[test]
18208    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
18209        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
18210        let files = names
18211            .iter()
18212            .map(|name| {
18213                let targets: Vec<&str> = names
18214                    .iter()
18215                    .filter(|other| *other != name)
18216                    .map(String::as_str)
18217                    .collect();
18218                (name.clone(), barrel_file(&targets))
18219            })
18220            .collect();
18221        let index = barrel_index(files);
18222
18223        assert_eq!(
18224            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
18225            None
18226        );
18227    }
18228
18229    /// Depth-dominance counterexample: the walk first reaches `shared` down a
18230    /// 16-hop chain (no budget left for its leaf), then reaches it again
18231    /// directly at depth 1. Plain visited-set pruning would skip the second
18232    /// visit and lose a resolution the capped resolver finds; the
18233    /// depth-dominance memo revisits because the second arrival is shallower.
18234    #[test]
18235    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
18236        let mut leaf = barrel_file(&[]);
18237        leaf.exports.insert("deep_symbol".to_string());
18238        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
18239        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
18240        // entry's SECOND reexport goes straight to shared.
18241        files.push((
18242            "src/entry.ts".to_string(),
18243            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
18244        ));
18245        for i in 0..15 {
18246            let next = if i == 14 {
18247                "src/shared.ts".to_string()
18248            } else {
18249                format!("src/chain{}.ts", i + 1)
18250            };
18251            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
18252        }
18253        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
18254        files.push(("src/leaf.ts".to_string(), leaf));
18255        let index = barrel_index(files);
18256
18257        assert_eq!(
18258            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
18259            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
18260            "a shallower re-visit must not be pruned by a deeper capped visit"
18261        );
18262    }
18263
18264    #[test]
18265    fn symbol_reachable_through_reexport_cycle_still_resolves() {
18266        let mut leaf = barrel_file(&[]);
18267        leaf.exports.insert("real_symbol".to_string());
18268        let index = barrel_index(vec![
18269            (
18270                "src/a.ts".to_string(),
18271                barrel_file(&["src/b.ts", "src/a.ts"]),
18272            ),
18273            (
18274                "src/b.ts".to_string(),
18275                barrel_file(&["src/a.ts", "src/leaf.ts"]),
18276            ),
18277            ("src/leaf.ts".to_string(), leaf),
18278        ]);
18279
18280        assert_eq!(
18281            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
18282            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
18283        );
18284    }
18285}
18286
18287#[cfg(test)]
18288mod method_dispatch_inference_tests {
18289    use super::*;
18290    use std::fs;
18291    use tempfile::tempdir;
18292
18293    #[test]
18294    fn java_field_receiver_type_selects_declared_class_method() {
18295        let source = r#"class EntryPoint {
18296    private UserService userService;
18297
18298    void handle() {
18299        userService.find();
18300    }
18301}
18302
18303class UserService {
18304    void find() {}
18305}
18306
18307class AuditService {
18308    void find() {}
18309}
18310"#;
18311        let dir = tempdir().expect("temp dir");
18312        let root = dir.path();
18313        write_fixture(root, "src/EntryPoint.java", source);
18314        let reference = reference(
18315            "java",
18316            "src/EntryPoint.java",
18317            "EntryPoint::handle",
18318            "userService",
18319            "find",
18320            line_of(source, "userService.find()"),
18321        );
18322        let mut cache = DispatchSourceCache::new();
18323
18324        let receiver_type =
18325            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
18326        assert_eq!(receiver_type, "UserService");
18327
18328        let candidates = vec![
18329            method_candidate("audit", "AuditService::find"),
18330            method_candidate("user", "UserService::find"),
18331        ];
18332        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
18333            .expect("type candidate");
18334        assert_eq!(selected.scoped_name, "UserService::find");
18335
18336        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
18337        assert!(
18338            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
18339        );
18340    }
18341
18342    #[test]
18343    fn kotlin_property_and_local_value_types_are_inferred() {
18344        let source = r#"class Handler {
18345    private val auditService: AuditService = AuditService()
18346
18347    fun handle() {
18348        auditService.find()
18349        val userService: UserService = UserService()
18350        userService.find()
18351        val billingService = BillingService()
18352        billingService.find()
18353    }
18354}
18355
18356class UserService { fun find() {} }
18357class AuditService { fun find() {} }
18358class BillingService { fun find() {} }
18359"#;
18360        let dir = tempdir().expect("temp dir");
18361        let root = dir.path();
18362        write_fixture(root, "src/Handler.kt", source);
18363        let mut cache = DispatchSourceCache::new();
18364
18365        let audit_ref = reference(
18366            "kotlin",
18367            "src/Handler.kt",
18368            "Handler::handle",
18369            "auditService",
18370            "find",
18371            line_of(source, "auditService.find()"),
18372        );
18373        assert_eq!(
18374            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
18375            Some("AuditService")
18376        );
18377
18378        let user_ref = reference(
18379            "kotlin",
18380            "src/Handler.kt",
18381            "Handler::handle",
18382            "userService",
18383            "find",
18384            line_of(source, "userService.find()"),
18385        );
18386        assert_eq!(
18387            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
18388            Some("UserService")
18389        );
18390
18391        let billing_ref = reference(
18392            "kotlin",
18393            "src/Handler.kt",
18394            "Handler::handle",
18395            "billingService",
18396            "find",
18397            line_of(source, "billingService.find()"),
18398        );
18399        assert_eq!(
18400            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
18401            Some("BillingService")
18402        );
18403    }
18404
18405    #[test]
18406    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
18407        let source = r#"struct Foo { void run(); };
18408struct PointerFoo { void run(); };
18409struct FactoryFoo { void run(); };
18410FactoryFoo makeFactoryFoo();
18411
18412void handle() {
18413    Foo foo;
18414    foo.run();
18415    PointerFoo* pointerFoo = nullptr;
18416    pointerFoo->run();
18417    auto factoryFoo = makeFactoryFoo();
18418    factoryFoo.run();
18419}
18420"#;
18421        let dir = tempdir().expect("temp dir");
18422        let root = dir.path();
18423        write_fixture(root, "src/fixture.cpp", source);
18424        let mut cache = DispatchSourceCache::new();
18425
18426        let foo_ref = reference(
18427            "cpp",
18428            "src/fixture.cpp",
18429            "handle",
18430            "foo",
18431            "run",
18432            line_of(source, "foo.run()"),
18433        );
18434        assert_eq!(
18435            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
18436            Some("Foo")
18437        );
18438
18439        let pointer_ref = reference(
18440            "cpp",
18441            "src/fixture.cpp",
18442            "handle",
18443            "pointerFoo",
18444            "run",
18445            line_of(source, "pointerFoo->run()"),
18446        );
18447        assert_eq!(
18448            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
18449            Some("PointerFoo")
18450        );
18451
18452        let factory_ref = reference(
18453            "cpp",
18454            "src/fixture.cpp",
18455            "handle",
18456            "factoryFoo",
18457            "run",
18458            line_of(source, "factoryFoo.run()"),
18459        );
18460        assert_eq!(
18461            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
18462            Some("FactoryFoo")
18463        );
18464    }
18465
18466    #[test]
18467    fn rust_direct_self_field_name_trims_separator_whitespace() {
18468        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
18469            assert_eq!(
18470                rust_direct_self_field_name(receiver_expression),
18471                Some("engine")
18472            );
18473        }
18474    }
18475
18476    #[test]
18477    fn rust_direct_self_field_receiver_type_is_conservative() {
18478        let source = r#"struct Engine;
18479
18480struct Car {
18481    engine: Engine,
18482}
18483
18484impl Car {
18485    fn run(&self) {
18486        self.engine.start();
18487    }
18488}
18489
18490struct NestedCar {
18491    engine: Engine,
18492}
18493
18494impl NestedCar {
18495    fn run(&self) {
18496        self.inner.engine.start();
18497    }
18498}
18499
18500struct WrappedCar {
18501    engine: Option<Engine>,
18502}
18503
18504impl WrappedCar {
18505    fn run(&self) {
18506        self.engine.start(); // wrapped
18507    }
18508}
18509
18510struct GenericCar<T> {
18511    engine: T,
18512}
18513
18514impl<T> GenericCar<T> {
18515    fn run(&self) {
18516        self.engine.start(); // generic
18517    }
18518}
18519
18520type EngineAlias = Engine;
18521
18522struct AliasCar {
18523    engine: EngineAlias,
18524}
18525
18526impl AliasCar {
18527    fn run(&self) {
18528        self.engine.start(); // alias
18529    }
18530}
18531"#;
18532        let dir = tempdir().expect("temp dir");
18533        let root = dir.path();
18534        write_fixture(root, "src/lib.rs", source);
18535        let mut cache = DispatchSourceCache::new();
18536
18537        let mut direct = reference(
18538            "rust",
18539            "src/lib.rs",
18540            "Car::run",
18541            "engine",
18542            "start",
18543            line_of(source, "self.engine.start()"),
18544        );
18545        direct.receiver_expression = "self.engine".to_string();
18546        assert_eq!(
18547            infer_receiver_type(root, &direct, &mut cache).as_deref(),
18548            Some("Engine")
18549        );
18550
18551        let mut mismatched_impl_target = direct.clone();
18552        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
18553        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
18554
18555        let mut nested = reference(
18556            "rust",
18557            "src/lib.rs",
18558            "NestedCar::run",
18559            "engine",
18560            "start",
18561            line_of(source, "self.inner.engine.start()"),
18562        );
18563        nested.receiver_expression = "self.inner.engine".to_string();
18564        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
18565
18566        let mut wrapped = reference(
18567            "rust",
18568            "src/lib.rs",
18569            "WrappedCar::run",
18570            "engine",
18571            "start",
18572            line_of(source, "self.engine.start(); // wrapped"),
18573        );
18574        wrapped.receiver_expression = "self.engine".to_string();
18575        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
18576
18577        let mut generic = reference(
18578            "rust",
18579            "src/lib.rs",
18580            "GenericCar::run",
18581            "engine",
18582            "start",
18583            line_of(source, "self.engine.start(); // generic"),
18584        );
18585        generic.receiver_expression = "self.engine".to_string();
18586        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
18587
18588        let mut alias = reference(
18589            "rust",
18590            "src/lib.rs",
18591            "AliasCar::run",
18592            "engine",
18593            "start",
18594            line_of(source, "self.engine.start(); // alias"),
18595        );
18596        alias.receiver_expression = "self.engine".to_string();
18597        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
18598    }
18599
18600    #[test]
18601    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
18602        let source = r#"struct Engine;
18603
18604struct Car {
18605    engine: &'static Engine,
18606}
18607
18608impl Car {
18609    fn run(&self) {
18610        self.engine.start();
18611    }
18612}
18613"#;
18614        let dir = tempdir().expect("temp dir");
18615        let root = dir.path();
18616        write_fixture(root, "src/lib.rs", source);
18617        let mut cache = DispatchSourceCache::new();
18618        let mut reference = reference(
18619            "rust",
18620            "src/lib.rs",
18621            "Car::run",
18622            "engine",
18623            "start",
18624            line_of(source, "self.engine.start()"),
18625        );
18626        reference.receiver_expression = "self.engine".to_string();
18627
18628        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18629    }
18630
18631    #[test]
18632    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
18633        let source = r#"trait Drive {
18634    fn run(&self);
18635}
18636
18637struct Engine;
18638
18639struct Car {
18640    engine: Engine,
18641}
18642
18643impl Drive for Car {
18644    fn run(&self) {
18645        self.engine.start();
18646    }
18647}
18648"#;
18649        let dir = tempdir().expect("temp dir");
18650        let root = dir.path();
18651        write_fixture(root, "src/lib.rs", source);
18652        let mut cache = DispatchSourceCache::new();
18653        let mut reference = reference(
18654            "rust",
18655            "src/lib.rs",
18656            "Car::run",
18657            "engine",
18658            "start",
18659            line_of(source, "self.engine.start()"),
18660        );
18661        reference.receiver_expression = "self.engine".to_string();
18662
18663        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18664    }
18665
18666    #[test]
18667    fn rust_self_field_does_not_bind_struct_from_another_module() {
18668        let source = r#"struct Engine;
18669
18670mod unrelated {
18671    struct Car {
18672        engine: Engine,
18673    }
18674}
18675
18676impl Car {
18677    fn run(&self) {
18678        self.engine.start();
18679    }
18680}
18681"#;
18682        let dir = tempdir().expect("temp dir");
18683        let root = dir.path();
18684        write_fixture(root, "src/lib.rs", source);
18685        let mut cache = DispatchSourceCache::new();
18686        let mut reference = reference(
18687            "rust",
18688            "src/lib.rs",
18689            "Car::run",
18690            "engine",
18691            "start",
18692            line_of(source, "self.engine.start()"),
18693        );
18694        reference.receiver_expression = "self.engine".to_string();
18695
18696        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18697    }
18698
18699    #[test]
18700    fn unknown_java_receiver_still_uses_name_match_fallback() {
18701        let source = r#"class EntryPoint {
18702    void handle() {
18703        service.runSpecial();
18704    }
18705}
18706
18707class OnlyService {
18708    void runSpecial() {}
18709}
18710"#;
18711        let dir = tempdir().expect("temp dir");
18712        let root = dir.path();
18713        write_fixture(root, "src/EntryPoint.java", source);
18714        let reference = reference(
18715            "java",
18716            "src/EntryPoint.java",
18717            "EntryPoint::handle",
18718            "service",
18719            "runSpecial",
18720            line_of(source, "service.runSpecial()"),
18721        );
18722        let mut cache = DispatchSourceCache::new();
18723
18724        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
18725        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
18726        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
18727        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
18728    }
18729
18730    fn reference(
18731        lang: &str,
18732        caller_file: &str,
18733        caller_symbol: &str,
18734        receiver: &str,
18735        method_name: &str,
18736        line: u32,
18737    ) -> NameMatchRef {
18738        NameMatchRef {
18739            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
18740            caller_node: format!("{caller_symbol}:node"),
18741            caller_file: caller_file.to_string(),
18742            caller_symbol: caller_symbol.to_string(),
18743            caller_signature: None,
18744            receiver_expression: receiver.to_string(),
18745            receiver: receiver.to_string(),
18746            method_name: method_name.to_string(),
18747            colon_dispatch: false,
18748            line,
18749            lang: lang.to_string(),
18750        }
18751    }
18752
18753    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
18754        NameMatchCandidate {
18755            node_id: node_id.to_string(),
18756            file_path: "src/targets.fixture".to_string(),
18757            scoped_name: scoped_name.to_string(),
18758            kind: "method".to_string(),
18759            start_line: 1,
18760        }
18761    }
18762
18763    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
18764        let path = root.join(rel_path);
18765        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
18766        fs::write(path, source).expect("write fixture");
18767    }
18768
18769    fn line_of(source: &str, needle: &str) -> u32 {
18770        source
18771            .lines()
18772            .position(|line| line.contains(needle))
18773            .map(|index| index as u32 + 1)
18774            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
18775    }
18776}
18777
18778#[cfg(test)]
18779mod bounded_build_breaker_tests {
18780    use super::*;
18781    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
18782    use tempfile::tempdir;
18783
18784    #[test]
18785    fn staged_inventory_drives_ordered_bounded_file_batches() {
18786        let temp = tempdir().unwrap();
18787        let root = temp.path().join("root");
18788        std::fs::create_dir_all(&root).unwrap();
18789        let first = root.join("a.ts");
18790        let second = root.join("b.ts");
18791        let third = root.join("c.ts");
18792        for path in [&first, &second, &third] {
18793            std::fs::write(path, "export function item() {}\n").unwrap();
18794        }
18795        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
18796            .unwrap()
18797            .expect("test root may write its private staging database");
18798        let store = CallGraphStore::open_at_path(
18799            root.clone(),
18800            "inventory-key".to_string(),
18801            temp.path().join("inventory.sqlite"),
18802            None,
18803            true,
18804            Some(writer_lease),
18805            None,
18806        )
18807        .unwrap()
18808        .store;
18809        let fingerprint = store
18810            .stage_cold_build_file_inventory(&[
18811                third.clone(),
18812                first.clone(),
18813                second.clone(),
18814                first.clone(),
18815            ])
18816            .unwrap();
18817
18818        let conn = store.conn.lock().unwrap();
18819        assert_eq!(
18820            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
18821            3,
18822            "the primary key deduplicates caller-supplied paths on disk"
18823        );
18824        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
18825            .unwrap()
18826            .expect("first batch");
18827        assert_eq!(first_batch.paths, vec![first.clone(), second]);
18828        let second_batch =
18829            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
18830                .unwrap()
18831                .expect("second batch");
18832        assert_eq!(second_batch.paths, vec![third]);
18833        assert_eq!(
18834            fingerprint,
18835            callgraph_corpus_fingerprint(&root).unwrap(),
18836            "staged and direct streaming fingerprints agree without walk-order dependence"
18837        );
18838    }
18839
18840    #[test]
18841    fn resumed_stage_preserves_committed_batch_and_counter() {
18842        let temp = tempdir().unwrap();
18843        let root = temp.path().join("root");
18844        std::fs::create_dir_all(&root).unwrap();
18845        let first = root.join("first.ts");
18846        let second = root.join("second.ts");
18847        std::fs::write(&first, "export function first() {}\n").unwrap();
18848        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
18849        let staging = temp.path().join("stage.sqlite");
18850        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
18851            .unwrap()
18852            .expect("test root may write its private staging database");
18853        let store = CallGraphStore::open_at_path(
18854            root.clone(),
18855            "test-key".to_string(),
18856            staging,
18857            None,
18858            true,
18859            Some(writer_lease),
18860            None,
18861        )
18862        .unwrap()
18863        .store;
18864        let corpus_fingerprint = store
18865            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
18866            .unwrap();
18867        let first_extract = build_file_extract(&root, &first).unwrap();
18868        let first_bytes = first_extract.freshness.size;
18869        {
18870            let mut conn = store.conn.lock().unwrap();
18871            let tx = conn.transaction().unwrap();
18872            clear_tables(&tx).unwrap();
18873            insert_meta(&tx).unwrap();
18874            drop_cold_build_secondary_indexes(&tx).unwrap();
18875            set_meta_ready(&tx, false).unwrap();
18876            set_staged_build_phase(&tx, "extracting").unwrap();
18877            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
18878            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
18879            {
18880                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
18881                insert_file_extract_prepared(
18882                    &mut inserts,
18883                    &root.display().to_string(),
18884                    &first_extract,
18885                )
18886                .unwrap();
18887                for raw in &first_extract.raw_refs {
18888                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
18889                }
18890            }
18891            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
18892            tx.commit().unwrap();
18893        }
18894
18895        store
18896            .cold_build_chunked(&[first.clone(), second.clone()], 1)
18897            .unwrap();
18898        let conn = store.conn.lock().unwrap();
18899        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
18900        assert_eq!(
18901            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18902            first_bytes + std::fs::metadata(second).unwrap().len(),
18903            "the already committed batch and its credit survive adoption; only the new batch increments credit"
18904        );
18905        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
18906    }
18907
18908    const SPECIMEN_CHILD_TEST: &str =
18909        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
18910    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
18911    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
18912    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
18913    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
18914
18915    fn wait_for_child_barrier(path: &Path) {
18916        let deadline = Instant::now() + Duration::from_secs(10);
18917        while !path.exists() {
18918            assert!(
18919                Instant::now() < deadline,
18920                "callgraph child did not reach barrier {}",
18921                path.display()
18922            );
18923            std::thread::sleep(Duration::from_millis(5));
18924        }
18925    }
18926
18927    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
18928        let signal = store.join("specimen-child.reached");
18929        let _ = std::fs::remove_file(&signal);
18930        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
18931        command
18932            .arg("--exact")
18933            .arg(SPECIMEN_CHILD_TEST)
18934            .arg("--nocapture")
18935            .arg("--test-threads=1")
18936            .env(SPECIMEN_CHILD_ROOT, root)
18937            .env(SPECIMEN_CHILD_STORE, store)
18938            .env(SPECIMEN_CHILD_SIGNAL, &signal)
18939            .stdout(std::process::Stdio::null())
18940            .stderr(std::process::Stdio::null());
18941        if let Some(phase) = phase {
18942            command.env(SPECIMEN_CHILD_PHASE, phase);
18943        }
18944        command.spawn().unwrap()
18945    }
18946
18947    fn staging_path(root: &Path, store: &Path) -> PathBuf {
18948        let project_key = crate::search_index::artifact_cache_key(root);
18949        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
18950    }
18951
18952    fn durable_staging_state(path: &Path) -> (u64, u64) {
18953        if !path.exists() {
18954            return (0, 0);
18955        }
18956        let conn = Connection::open(path).unwrap();
18957        (
18958            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
18959            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
18960        )
18961    }
18962
18963    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
18964        wait_for_child_barrier(signal);
18965        child.kill().unwrap();
18966        let _ = child.wait().unwrap();
18967    }
18968
18969    #[test]
18970    fn respawn_loop_build_child() {
18971        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
18972            return;
18973        };
18974        let root = PathBuf::from(root);
18975        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
18976        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
18977            let phase = phase.to_string_lossy().into_owned();
18978            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
18979            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
18980                if observed == phase {
18981                    std::fs::write(&signal, observed.as_bytes()).unwrap();
18982                    std::thread::sleep(Duration::from_secs(30));
18983                }
18984            })));
18985        }
18986        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
18987        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
18988    }
18989
18990    #[test]
18991    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
18992        let temp = tempdir().unwrap();
18993        let root = temp.path().join("resumable-root");
18994        let store = temp.path().join("resumable-store");
18995        std::fs::create_dir_all(&root).unwrap();
18996        std::fs::create_dir_all(&store).unwrap();
18997        for index in 0..3 {
18998            std::fs::write(
18999                root.join(format!("file-{index}.ts")),
19000                format!("export function specimen{index}() {{ return {index}; }}\n"),
19001            )
19002            .unwrap();
19003        }
19004        let stage = staging_path(&root, &store);
19005        let signal = store.join("specimen-child.reached");
19006
19007        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19008        kill_barrier_child(&mut first, &signal);
19009        let (first_rows, first_bytes) = durable_staging_state(&stage);
19010        assert_eq!(first_rows, 1);
19011        assert!(first_bytes > 0);
19012
19013        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19014        kill_barrier_child(&mut second, &signal);
19015        let (second_rows, second_bytes) = durable_staging_state(&stage);
19016        assert_eq!(second_rows, 2);
19017        assert!(
19018            second_bytes > first_bytes,
19019            "a replacement process must adopt committed bytes instead of restarting from zero"
19020        );
19021
19022        let status = spawn_build_child(&root, &store, None).wait().unwrap();
19023        assert!(status.success(), "uninterrupted replacement build failed");
19024        assert!(!stage.exists(), "published staging file must be renamed");
19025        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
19026            .unwrap()
19027            .expect("replacement attempts must converge to a published graph");
19028        assert_eq!(ready.indexed_file_count().unwrap(), 3);
19029
19030        let fast_root = temp.path().join("zero-credit-root");
19031        let fast_store = temp.path().join("zero-credit-store");
19032        std::fs::create_dir_all(&fast_root).unwrap();
19033        std::fs::create_dir_all(&fast_store).unwrap();
19034        std::fs::write(
19035            fast_root.join("main.ts"),
19036            "export function neverCommitted() {}\n",
19037        )
19038        .unwrap();
19039        let fast_stage = staging_path(&fast_root, &fast_store);
19040        let fast_signal = fast_store.join("specimen-child.reached");
19041        let breaker_path = fast_store.join("build-breaker.sqlite");
19042        let now = unix_millis_now();
19043
19044        for death in 0..3 {
19045            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
19046            wait_for_child_barrier(&fast_signal);
19047            let attempt_id = Connection::open(&breaker_path)
19048                .unwrap()
19049                .query_row(
19050                    "SELECT attempt_id FROM breaker_attempts
19051                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
19052                    [],
19053                    |row| row.get::<_, String>(0),
19054                )
19055                .unwrap();
19056            let (_, committed_bytes) = durable_staging_state(&fast_stage);
19057            assert_eq!(
19058                committed_bytes, 0,
19059                "the fast-kill schedule must not cross an extraction commit"
19060            );
19061            child.kill().unwrap();
19062            let _ = child.wait().unwrap();
19063
19064            let key = BreakerKey::new(
19065                fast_root.display().to_string(),
19066                BuildDomain::CallgraphCold,
19067                callgraph_corpus_fingerprint(&fast_root).unwrap(),
19068            );
19069            BuildDeathBreaker::open(&breaker_path)
19070                .unwrap()
19071                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
19072                .unwrap();
19073        }
19074
19075        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
19076        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
19077            .unwrap()
19078            .expect("three zero-credit process deaths must suspend the root");
19079        assert_eq!(suspension.reason, "zero_credit_death_limit");
19080        assert_eq!(suspension.death_count, 3);
19081        let response = crate::commands::callgraph_store_adapter::suspended_response(
19082            "specimen",
19083            "callers",
19084            &suspension,
19085        );
19086        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
19087        let message = response.data["message"].as_str().unwrap();
19088        assert!(
19089            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
19090        );
19091        assert!(message.ends_with(
19092            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
19093        ));
19094        let refused =
19095            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
19096                .expect_err("a suspended root must not report a perpetually building worker");
19097        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
19098    }
19099
19100    #[test]
19101    fn published_callgraph_build_respects_durable_domain_suspension() {
19102        let temp = tempdir().unwrap();
19103        let root = temp.path().join("root");
19104        let store_dir = temp.path().join("store");
19105        std::fs::create_dir_all(&root).unwrap();
19106        let source = root.join("main.ts");
19107        std::fs::write(&source, "export function marker() {}\n").unwrap();
19108        let files = vec![source];
19109        let key = BreakerKey::new(
19110            root.display().to_string(),
19111            BuildDomain::CallgraphCold,
19112            callgraph_corpus_fingerprint(&root).unwrap(),
19113        );
19114        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
19115        for _ in 0..3 {
19116            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
19117                panic!("unexpected early suspension");
19118            };
19119            breaker
19120                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
19121                .unwrap();
19122        }
19123
19124        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
19125            .expect_err("durably tripped callgraph domain must refuse a new cold build");
19126        assert!(matches!(
19127            error,
19128            CallGraphStoreError::Suspended(ref suspension)
19129                if suspension.domain == BuildDomain::CallgraphCold
19130                    && suspension.death_count == 3
19131        ));
19132    }
19133}