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
8pub(crate) mod disk_facts;
9pub(crate) mod facts;
10pub mod join;
11use disk_facts::DiskFacts;
12use facts::{byte_path, EntryKind, FactPaths, ProjectFacts};
13
14use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
15use crate::callgraph::{self, EdgeResolution, FileCallData, TraceToSymbolCandidate};
16use crate::context::SubcLifecycleAdmission;
17use crate::db::{SqliteStore, TrackedConnection};
18use crate::error::AftError;
19use crate::imports::{ImportForm, ImportGroup, ImportKind, ImportStatement};
20use crate::parser::{grammar_for, parse_source_with_cached_parser, LangId};
21use crate::symbols::{Range, SymbolKind};
22use rayon::prelude::*;
23use rusqlite::{
24    params, params_from_iter, Connection, OpenFlags, OptionalExtension, Statement, Transaction,
25};
26use std::cell::RefCell;
27use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
28use std::fmt;
29use std::io::Read;
30use std::path::{Path, PathBuf};
31use std::rc::Rc;
32use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
33use std::sync::{Arc, Condvar, Mutex, OnceLock};
34use std::thread::JoinHandle;
35use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
36use tree_sitter::{Node, Parser};
37
38const SCHEMA_VERSION: i64 = 1;
39const BACKEND_TREESITTER: &str = "treesitter";
40pub(crate) const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
41const PROVENANCE_NAME_MATCH: &str = "name_match";
42const PROVENANCE_TYPE_MATCH: &str = "type_match";
43const PROVENANCE_VALUE_REF: &str = "value_ref";
44const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
45const TOP_LEVEL_SYMBOL: &str = "<top-level>";
46const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
47const MIGRATION_MANIFEST_VERSION: u32 = 1;
48const MIGRATION_GENERATION_TAG: &str = ".migrated.";
49const MIGRATION_BACKUP_PAGES_PER_STEP: i32 = 128;
50const MIGRATION_BACKUP_RETRY_BUDGET: usize = 25;
51const MIGRATION_BACKUP_WALL_CLOCK_BUDGET: Duration = Duration::from_secs(10);
52const SQLITE_FILE_SET_SUFFIXES: &[&str] = &["", "-wal", "-shm", "-journal"];
53/// Marker-protected generations older than this absolute age are reclaimed even
54/// if a stale reader marker remains. Current and newest-previous generations are
55/// always retained, bounding the root-keyed callgraph store to roughly two or
56/// three large generations without adding user-visible configuration.
57const MARKED_GENERATION_RETENTION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
58const REFRESH_WORKER_WARN_AFTER: Duration = Duration::from_secs(5);
59const REFRESH_WORKER_FINAL_AFTER: Duration = Duration::from_secs(30);
60pub const REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET: Duration = Duration::from_millis(100);
61const REBUILD_COOLDOWN: Duration = Duration::from_secs(30);
62const ROOT_REPAIR_WARN_INTERVAL: Duration = Duration::from_secs(60);
63const CALLGRAPH_WRITE_METRIC_WINDOW: Duration = Duration::from_secs(60);
64const CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES: i64 = 4_000;
65/// Keep SQLite's per-connection page cache below the staged build working-set
66/// budget; negative values are KiB per SQLite's `cache_size` pragma.
67const CALLGRAPH_SQLITE_CACHE_KIB: i64 = -8 * 1024;
68const REFRESH_IDLE_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
69/// A root removed from `cache-keys.json` cannot be reached by a future checkout.
70/// Wait the same seven-day grace period as cache-key eviction before deleting its
71/// callgraph directory so an interrupted configuration never loses recent data.
72const CALLGRAPH_ROOT_ORPHAN_MIN_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
73/// One publish must not spend unbounded time walking a large artifact store. The
74/// cursor resumes after this many root-keyed directories on the next publication.
75const CALLGRAPH_ROOT_SWEEP_LIMIT: usize = 200;
76const CALLGRAPH_ROOT_SWEEP_BUDGET: Duration = Duration::from_secs(5);
77static CALLGRAPH_ROOT_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
78
79// Cold-build working-set limits are implementation constants rather than user
80// knobs so a large non-git root cannot accidentally opt back into an OOM path.
81const COLD_BUILD_EXTRACT_BATCH_FILES: usize = 256;
82const COLD_BUILD_EXTRACT_BATCH_BYTES: u64 = 32 * 1024 * 1024;
83// A 20k-reference resolver window kept peak RSS working-set shaped in the
84// committed 20k/40k corpus harness; 100k rows did not.
85const COLD_BUILD_RESOLVE_WINDOW: usize = 20_000;
86const DISK_FILE_INDEX_MEMO_CAPACITY: usize = 4_096;
87const STAGED_COMMITTED_EXTRACTED_BYTES: &str = "committed_extracted_bytes";
88const STAGED_RESOLVE_CURSOR: &str = "resolve_cursor";
89const STAGED_BUILD_PHASE: &str = "staged_build_phase";
90const STAGED_CORPUS_FINGERPRINT: &str = "staged_corpus_fingerprint";
91
92type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
93pub type ColdBuildPhaseObserver = dyn Fn(&'static str) + Send + Sync + 'static;
94#[cfg(test)]
95type ColdBuildSliceObserver = dyn Fn(&'static str, usize, usize) + Send + Sync + 'static;
96#[cfg(test)]
97type ColdBuildExtractObserver = dyn Fn(&[PathBuf]) + Send + Sync + 'static;
98
99#[cfg(test)]
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub(crate) struct ProjectionMutationCounts {
102    pub revision_bumps: usize,
103    pub journal_appends: usize,
104}
105
106#[cfg(test)]
107thread_local! {
108    static PROJECTION_MUTATION_COUNTS: std::cell::Cell<ProjectionMutationCounts> =
109        const { std::cell::Cell::new(ProjectionMutationCounts { revision_bumps: 0, journal_appends: 0 }) };
110}
111
112#[cfg(test)]
113fn note_projection_revision_bump_for_test() {
114    PROJECTION_MUTATION_COUNTS.with(|counts| {
115        let mut current = counts.get();
116        current.revision_bumps += 1;
117        counts.set(current);
118    });
119}
120
121#[cfg(test)]
122pub(super) fn note_projection_journal_append_for_test() {
123    PROJECTION_MUTATION_COUNTS.with(|counts| {
124        let mut current = counts.get();
125        current.journal_appends += 1;
126        counts.set(current);
127    });
128}
129
130#[cfg(test)]
131pub(crate) fn take_projection_mutation_counts_for_test() -> ProjectionMutationCounts {
132    PROJECTION_MUTATION_COUNTS.with(|counts| counts.replace(ProjectionMutationCounts::default()))
133}
134
135static COLD_BUILD_PHASE_OBSERVER: OnceLock<Mutex<Option<Arc<ColdBuildPhaseObserver>>>> =
136    OnceLock::new();
137
138/// Install a process-local phase hook for the reproducible cold-build harness.
139/// Production callers leave it unset, so phase reporting adds no allocation on
140/// the build path.
141pub fn set_cold_build_phase_observer(observer: Option<Arc<ColdBuildPhaseObserver>>) {
142    *COLD_BUILD_PHASE_OBSERVER
143        .get_or_init(|| Mutex::new(None))
144        .lock()
145        .expect("cold build phase observer mutex poisoned") = observer;
146}
147
148fn note_cold_build_phase(phase: &'static str) {
149    if let Some(observer) = COLD_BUILD_PHASE_OBSERVER
150        .get_or_init(|| Mutex::new(None))
151        .lock()
152        .expect("cold build phase observer mutex poisoned")
153        .as_ref()
154        .cloned()
155    {
156        observer(phase);
157    }
158}
159
160#[cfg(test)]
161fn note_cold_build_commit_barrier(phase: &'static str) {
162    note_cold_build_phase(phase);
163}
164
165#[cfg(not(test))]
166fn note_cold_build_commit_barrier(_phase: &'static str) {}
167
168#[derive(Clone, Debug, Eq, Hash, PartialEq)]
169struct RebuildCooldownKey {
170    callgraph_dir: PathBuf,
171    project_key: String,
172}
173
174#[derive(Clone, Debug)]
175struct RebuildCooldownRecord {
176    project_root: PathBuf,
177    published_at: Instant,
178    cross_root_cooldown_armed: bool,
179}
180
181// Prevent repeated rebuilds when requests rapidly switch between project
182// roots. Allow the first successful rebuild for a different root; after that
183// transition, report the artifact as unavailable instead of publishing another
184// complete generation. Record only successful publications in this map.
185static SUCCESSFUL_REBUILDS: OnceLock<Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>> =
186    OnceLock::new();
187
188#[derive(Clone, Debug, Eq, Hash, PartialEq)]
189struct RootRepairWarningKey {
190    project_key: String,
191}
192
193#[derive(Clone, Debug)]
194struct RootRepairWarningRecord {
195    window_start: Instant,
196    last_emitted: Instant,
197    entry_count: u64,
198    suppressed: u64,
199}
200
201static ROOT_REPAIR_WARNINGS: OnceLock<
202    Mutex<HashMap<RootRepairWarningKey, RootRepairWarningRecord>>,
203> = OnceLock::new();
204
205#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
206pub(crate) struct CallgraphWriteMetricsSnapshot {
207    pub commits_60s: u64,
208    pub pages_or_bytes_written_60s: u64,
209}
210
211#[derive(Debug, Default)]
212struct CallgraphWriteMetrics {
213    window_start_ms: AtomicU64,
214    commits_60s: AtomicU64,
215    pages_or_bytes_written_60s: AtomicU64,
216}
217
218static CALLGRAPH_WRITE_METRICS: OnceLock<Mutex<HashMap<String, Arc<CallgraphWriteMetrics>>>> =
219    OnceLock::new();
220
221fn callgraph_write_metrics_for_key(project_key: &str) -> Arc<CallgraphWriteMetrics> {
222    let metrics = CALLGRAPH_WRITE_METRICS.get_or_init(|| Mutex::new(HashMap::new()));
223    let mut metrics = metrics
224        .lock()
225        .expect("callgraph write metrics mutex poisoned");
226    Arc::clone(
227        metrics
228            .entry(project_key.to_string())
229            .or_insert_with(|| Arc::new(CallgraphWriteMetrics::default())),
230    )
231}
232
233fn roll_callgraph_write_metric_window(metrics: &CallgraphWriteMetrics, now_ms: u64) {
234    let current_start = metrics.window_start_ms.load(AtomicOrdering::Acquire);
235    if current_start == 0 {
236        let _ = metrics.window_start_ms.compare_exchange(
237            0,
238            now_ms,
239            AtomicOrdering::AcqRel,
240            AtomicOrdering::Acquire,
241        );
242        return;
243    }
244    if now_ms.saturating_sub(current_start) < CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64 {
245        return;
246    }
247    if metrics
248        .window_start_ms
249        .compare_exchange(
250            current_start,
251            now_ms,
252            AtomicOrdering::AcqRel,
253            AtomicOrdering::Acquire,
254        )
255        .is_ok()
256    {
257        metrics.commits_60s.store(0, AtomicOrdering::Release);
258        metrics
259            .pages_or_bytes_written_60s
260            .store(0, AtomicOrdering::Release);
261    }
262}
263
264impl CallgraphWriteMetrics {
265    fn record_commit(&self, pages_or_bytes_written: u64) {
266        let now_ms = unix_millis_now();
267        roll_callgraph_write_metric_window(self, now_ms);
268        self.commits_60s.fetch_add(1, AtomicOrdering::Relaxed);
269        self.pages_or_bytes_written_60s
270            .fetch_add(pages_or_bytes_written, AtomicOrdering::Relaxed);
271    }
272
273    fn snapshot(&self) -> CallgraphWriteMetricsSnapshot {
274        roll_callgraph_write_metric_window(self, unix_millis_now());
275        CallgraphWriteMetricsSnapshot {
276            commits_60s: self.commits_60s.load(AtomicOrdering::Acquire),
277            pages_or_bytes_written_60s: self
278                .pages_or_bytes_written_60s
279                .load(AtomicOrdering::Acquire),
280        }
281    }
282}
283
284pub(crate) fn callgraph_write_metrics_for_project(
285    project_key: &str,
286) -> CallgraphWriteMetricsSnapshot {
287    callgraph_write_metrics_for_key(project_key).snapshot()
288}
289
290pub(crate) fn callgraph_write_metrics_total() -> CallgraphWriteMetricsSnapshot {
291    let Some(metrics) = CALLGRAPH_WRITE_METRICS.get() else {
292        return CallgraphWriteMetricsSnapshot::default();
293    };
294    let metrics = metrics
295        .lock()
296        .expect("callgraph write metrics mutex poisoned");
297    metrics.values().map(|metrics| metrics.snapshot()).fold(
298        CallgraphWriteMetricsSnapshot::default(),
299        |total, current| CallgraphWriteMetricsSnapshot {
300            commits_60s: total.commits_60s.saturating_add(current.commits_60s),
301            pages_or_bytes_written_60s: total
302                .pages_or_bytes_written_60s
303                .saturating_add(current.pages_or_bytes_written_60s),
304        },
305    )
306}
307
308const ROOT_REPAIR_WARNING_TEXT: &str =
309    "callgraph store root repair requires rebuild; open-only reader reports unavailable";
310
311fn next_root_repair_warning(key: RootRepairWarningKey, now: Instant) -> Option<String> {
312    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
313    let mut warnings = warnings.lock().ok()?;
314    let entry = warnings.entry(key);
315    let record = match entry {
316        Entry::Vacant(entry) => {
317            entry.insert(RootRepairWarningRecord {
318                window_start: now,
319                last_emitted: now,
320                entry_count: 1,
321                suppressed: 0,
322            });
323            return Some(ROOT_REPAIR_WARNING_TEXT.to_string());
324        }
325        Entry::Occupied(entry) => entry.into_mut(),
326    };
327
328    if now.saturating_duration_since(record.window_start) >= ROOT_REPAIR_WARN_INTERVAL {
329        let suppressed = record.suppressed;
330        record.window_start = now;
331        record.last_emitted = now;
332        record.entry_count = 1;
333        record.suppressed = 0;
334        return Some(if suppressed == 0 {
335            ROOT_REPAIR_WARNING_TEXT.to_string()
336        } else {
337            format!("{ROOT_REPAIR_WARNING_TEXT} (repeated {suppressed}x in 60s)")
338        });
339    }
340
341    record.entry_count = record.entry_count.saturating_add(1);
342    if now.saturating_duration_since(record.last_emitted) < ROOT_REPAIR_WARN_INTERVAL {
343        record.suppressed = record.suppressed.saturating_add(1);
344        None
345    } else {
346        record.last_emitted = now;
347        Some(ROOT_REPAIR_WARNING_TEXT.to_string())
348    }
349}
350
351pub(crate) fn note_repair_entry(project_key: &str) -> Option<String> {
352    next_root_repair_warning(
353        RootRepairWarningKey {
354            project_key: project_key.to_string(),
355        },
356        Instant::now(),
357    )
358}
359
360/// Return the number of repair entries in the active 60-second window.
361///
362/// The window start is returned for callers that need to show freshness without
363/// adding another status verdict or turning this into a user-facing setting.
364pub(crate) fn repair_entry_rate(project_key: &str) -> Option<(u64, Instant)> {
365    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
366    let warnings = warnings.lock().ok()?;
367    let record = warnings.get(&RootRepairWarningKey {
368        project_key: project_key.to_string(),
369    })?;
370    (Instant::now().saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL)
371        .then_some((record.entry_count, record.window_start))
372}
373
374pub(crate) fn repair_entry_rate_total() -> u64 {
375    let Ok(warnings) = ROOT_REPAIR_WARNINGS
376        .get_or_init(|| Mutex::new(HashMap::new()))
377        .lock()
378    else {
379        return 0;
380    };
381    let now = Instant::now();
382    warnings
383        .values()
384        .filter(|record| {
385            now.saturating_duration_since(record.window_start) < ROOT_REPAIR_WARN_INTERVAL
386        })
387        .map(|record| record.entry_count)
388        .sum()
389}
390
391#[cfg(test)]
392pub(crate) fn expire_repair_entry_window_for_test(project_key: &str) {
393    let warnings = ROOT_REPAIR_WARNINGS.get_or_init(|| Mutex::new(HashMap::new()));
394    let mut warnings = warnings.lock().unwrap();
395    if let Some(record) = warnings.get_mut(&RootRepairWarningKey {
396        project_key: project_key.to_string(),
397    }) {
398        record.window_start = Instant::now() - ROOT_REPAIR_WARN_INTERVAL;
399    }
400}
401
402#[cfg(test)]
403mod root_repair_warning_tests {
404    use super::*;
405
406    #[test]
407    fn repair_warning_emits_once_then_reemits_with_suppressed_count() {
408        let key = RootRepairWarningKey {
409            project_key: "test-project".to_string(),
410        };
411        let first_at = Instant::now();
412        let first = next_root_repair_warning(key.clone(), first_at).unwrap();
413        assert_eq!(first, ROOT_REPAIR_WARNING_TEXT);
414        assert!(next_root_repair_warning(key.clone(), first_at + Duration::from_secs(1)).is_none());
415        assert_eq!(
416            repair_entry_rate("test-project").map(|rate| rate.0),
417            Some(2)
418        );
419
420        let repeated = next_root_repair_warning(key, first_at + ROOT_REPAIR_WARN_INTERVAL).unwrap();
421        assert!(repeated.ends_with("(repeated 1x in 60s)"));
422        expire_repair_entry_window_for_test("test-project");
423        assert!(repair_entry_rate("test-project").is_none());
424    }
425}
426
427#[cfg(test)]
428mod write_amplification_tests {
429    use super::*;
430    use std::fs;
431    use tempfile::tempdir;
432
433    #[test]
434    fn callgraph_writer_waits_when_wal_setup_meets_a_write_lock() {
435        let temp = tempdir().unwrap();
436        let sqlite_path = temp.path().join("contended.sqlite");
437        let blocker = Connection::open(&sqlite_path).expect("open blocking connection");
438        blocker
439            .execute_batch(
440                "PRAGMA journal_mode=DELETE;
441                 CREATE TABLE lock_probe (value INTEGER NOT NULL);
442                 INSERT INTO lock_probe VALUES (1);
443                 BEGIN EXCLUSIVE;
444                 UPDATE lock_probe SET value = 2;",
445            )
446            .expect("hold exclusive write transaction");
447
448        let (started_tx, started_rx) = std::sync::mpsc::channel();
449        let configure = std::thread::spawn(move || {
450            let conn = Connection::open(sqlite_path).expect("open contending connection");
451            started_tx.send(()).expect("signal configure start");
452            configure_connection(&conn)
453        });
454        started_rx.recv().expect("configure thread started");
455        std::thread::sleep(Duration::from_millis(100));
456        blocker.execute_batch("COMMIT").expect("release write lock");
457
458        configure
459            .join()
460            .expect("configure thread joined")
461            .expect("WAL setup waits for the writer instead of failing locked");
462    }
463
464    #[test]
465    fn callgraph_writer_and_reader_use_bounded_normal_pragmas() {
466        let temp = tempdir().unwrap();
467        let root = temp.path().join("root");
468        fs::create_dir_all(&root).unwrap();
469        let source = root.join("main.ts");
470        fs::write(&source, "export function main() {}\n").unwrap();
471        let store_dir = temp.path().join("store");
472        let store = CallGraphStore::open(store_dir.clone(), root.clone()).unwrap();
473
474        let conn = store.conn.lock().unwrap();
475        let synchronous: i64 = conn
476            .pragma_query_value(None, "synchronous", |row| row.get(0))
477            .unwrap();
478        let autocheckpoint: i64 = conn
479            .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
480            .unwrap();
481        let cache_size: i64 = conn
482            .pragma_query_value(None, "cache_size", |row| row.get(0))
483            .unwrap();
484        assert_eq!(synchronous, 1, "NORMAL synchronous mode is value 1");
485        assert_eq!(autocheckpoint, CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES);
486        assert_eq!(cache_size, CALLGRAPH_SQLITE_CACHE_KIB);
487        drop(conn);
488        store.cold_build(std::slice::from_ref(&source)).unwrap();
489        drop(store);
490
491        let readonly = CallGraphStore::open_readonly(store_dir, root)
492            .unwrap()
493            .expect("writer-created empty schema should be readable");
494        let conn = readonly.inner.conn.lock().unwrap();
495        let synchronous: i64 = conn
496            .pragma_query_value(None, "synchronous", |row| row.get(0))
497            .unwrap();
498        assert_eq!(synchronous, 1);
499    }
500
501    #[test]
502    fn own_refresh_skips_identical_extract_but_not_position_shift() {
503        let temp = tempdir().unwrap();
504        let root = temp.path().join("root");
505        fs::create_dir_all(&root).unwrap();
506        let source = root.join("main.ts");
507        fs::write(&source, "export function main() { return 1; }\n").unwrap();
508        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
509        store.cold_build(std::slice::from_ref(&source)).unwrap();
510        let write_metrics = callgraph_write_metrics_for_project(store.project_key());
511        assert!(write_metrics.commits_60s > 0);
512        assert!(write_metrics.pages_or_bytes_written_60s > 0);
513
514        let before = store.conn.lock().unwrap().total_changes();
515        fs::write(&source, "export function main() { return 1; }\n\n").unwrap();
516        let (stats, _) = store
517            .refresh_files_profiled(std::slice::from_ref(&source))
518            .unwrap();
519        let after = store.conn.lock().unwrap().total_changes();
520        assert_eq!(stats.unchanged_extract_files, 1);
521        assert_eq!(stats.refreshed_own_files, 0);
522        assert_eq!(
523            after - before,
524            2,
525            "only files and backend freshness update; graph-neutral edits retain the projection revision"
526        );
527
528        fs::write(&source, "\nexport function main() { return 1; }\n\n").unwrap();
529        let (shifted_stats, _) = store
530            .refresh_files_profiled(std::slice::from_ref(&source))
531            .unwrap();
532        assert_eq!(shifted_stats.unchanged_extract_files, 0);
533        assert_eq!(shifted_stats.refreshed_own_files, 1);
534    }
535
536    #[test]
537    fn revision_bumps_always_have_journal_entries_and_graph_neutral_saves_have_neither() {
538        let temp = tempdir().unwrap();
539        let root = temp.path().join("root");
540        fs::create_dir_all(&root).unwrap();
541        let source = root.join("main.ts");
542        fs::write(&source, "export function before() { return 1; }\n").unwrap();
543        let store = CallGraphStore::open(temp.path().join("store"), root).unwrap();
544        store.cold_build(std::slice::from_ref(&source)).unwrap();
545        let (baseline_revision, mut snapshot) =
546            dead_code_projection::project_dead_code_snapshot_with_revision(store.sqlite_path())
547                .unwrap();
548        let mut revision = baseline_revision.unwrap();
549        take_projection_mutation_counts_for_test();
550
551        for contents in [
552            "export function middle() { return 2; }\n",
553            "export function after() { return 3; }\n",
554        ] {
555            fs::write(&source, contents).unwrap();
556            store
557                .mark_files_stale(std::slice::from_ref(&source))
558                .unwrap();
559            store.refresh_files(std::slice::from_ref(&source)).unwrap();
560
561            let counts = take_projection_mutation_counts_for_test();
562            assert_eq!(
563                counts.revision_bumps, counts.journal_appends,
564                "every revision advance in an edit-save sequence must append its caller delta"
565            );
566            assert_eq!(counts.revision_bumps, 1);
567            let (next_revision, next_snapshot, verdict) =
568                dead_code_projection::project_dead_code_snapshot_incremental(
569                    store.sqlite_path(),
570                    Some((revision, &snapshot)),
571                )
572                .unwrap();
573            assert_eq!(verdict.kind, dead_code_projection::ProjectionKind::Spliced);
574            revision = next_revision.unwrap();
575            snapshot = next_snapshot;
576        }
577
578        fs::write(&source, "export function after() { return 3; }\n").unwrap();
579        store
580            .mark_files_stale(std::slice::from_ref(&source))
581            .unwrap();
582        store.refresh_files(std::slice::from_ref(&source)).unwrap();
583        assert_eq!(
584            take_projection_mutation_counts_for_test(),
585            ProjectionMutationCounts::default(),
586            "a graph-neutral save must neither advance the revision nor append a delta"
587        );
588        assert_eq!(store.projection_write_revision().unwrap(), Some(revision));
589    }
590
591    #[test]
592    fn one_file_no_graph_delta_refresh_appends_at_most_four_wal_pages_per_changed_row() {
593        const FUNCTION_COUNT: usize = 256;
594        const LOGICAL_ROWS_CHANGED: u64 = 2;
595        const MAX_WAL_PAGES_PER_CHANGED_ROW: u64 = 4;
596
597        let temp = tempdir().unwrap();
598        let root = temp.path().join("root");
599        fs::create_dir_all(&root).unwrap();
600        let source = root.join("large.ts");
601        let dependency = root.join("dependency.ts");
602        fs::write(&dependency, "export function dependency() { return 1; }\n").unwrap();
603        let mut contents = String::from("import { dependency } from './dependency';\n");
604        for index in 0..FUNCTION_COUNT {
605            let next = (index + 1) % FUNCTION_COUNT;
606            contents.push_str(&format!(
607                "export function symbol{index}() {{ console.log(symbol{next}()); return dependency(); }}\n"
608            ));
609        }
610        fs::write(&source, &contents).unwrap();
611
612        let store = CallGraphStore::open(temp.path().join("store"), root).unwrap();
613        store.cold_build(&[source.clone(), dependency]).unwrap();
614        assert!(store.checkpoint_wal_truncate());
615        let wal_path = sqlite_file_set_path(store.sqlite_path(), "-wal");
616        assert_eq!(
617            fs::metadata(&wal_path).map(|meta| meta.len()).unwrap_or(0),
618            0
619        );
620
621        contents.push_str("// Graph-neutral watcher edit.\n");
622        fs::write(&source, contents).unwrap();
623        let changes_before = store.conn.lock().unwrap().total_changes();
624        let stats = store.refresh_files(std::slice::from_ref(&source)).unwrap();
625        let conn = store.conn.lock().unwrap();
626        let changes_after = conn.total_changes();
627        let page_size: u64 = conn
628            .pragma_query_value(None, "page_size", |row| row.get(0))
629            .unwrap();
630        drop(conn);
631
632        let wal_bytes = fs::metadata(&wal_path).unwrap().len();
633        let max_wal_frames = LOGICAL_ROWS_CHANGED * MAX_WAL_PAGES_PER_CHANGED_ROW;
634        let max_wal_bytes = 32 + max_wal_frames * (page_size + 24);
635        assert!(
636            wal_bytes <= max_wal_bytes,
637            "one-file graph-neutral refresh appended {wal_bytes} WAL bytes; bound is {max_wal_bytes} bytes ({max_wal_frames} pages for {LOGICAL_ROWS_CHANGED} changed rows)"
638        );
639        assert_eq!(stats.unchanged_extract_files, 1);
640        assert_eq!(stats.refreshed_own_files, 0);
641        assert_eq!(changes_after - changes_before, LOGICAL_ROWS_CHANGED);
642    }
643
644    #[cfg(unix)]
645    #[test]
646    fn deleted_symlink_alias_refresh_removes_the_original_stale_row() {
647        let temp = tempdir().unwrap();
648        let root = temp.path().join("project");
649        let source = root.join("src/lib.ts");
650        fs::create_dir_all(source.parent().unwrap()).unwrap();
651        fs::write(&source, "export function live() {}\n").unwrap();
652        let alias = temp.path().join("project-alias");
653        std::os::unix::fs::symlink(&root, &alias).unwrap();
654        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
655        store.cold_build(std::slice::from_ref(&source)).unwrap();
656        store
657            .mark_files_stale(std::slice::from_ref(&source))
658            .unwrap();
659
660        fs::remove_file(&source).unwrap();
661        let stats = store
662            .refresh_files(&[alias.join("src/lib.ts")])
663            .expect("deleted alias path must resolve through its existing parent");
664
665        assert_eq!(stats.deleted_files, vec!["src/lib.ts"]);
666        assert!(store.stale_files().unwrap().is_empty());
667    }
668
669    #[cfg(unix)]
670    #[test]
671    fn symlink_alias_refresh_preserves_real_mutation_detection() {
672        let temp = tempdir().unwrap();
673        let root = temp.path().join("project");
674        let source = root.join("src/lib.ts");
675        fs::create_dir_all(source.parent().unwrap()).unwrap();
676        fs::write(&source, "export function before() {}\n").unwrap();
677        let alias = temp.path().join("project-alias");
678        std::os::unix::fs::symlink(&root, &alias).unwrap();
679        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
680        store.cold_build(std::slice::from_ref(&source)).unwrap();
681
682        fs::write(&source, "export function after() {}\n").unwrap();
683        let stats = store.refresh_files(&[alias.join("src/lib.ts")]).unwrap();
684
685        assert_eq!(stats.changed_files, vec!["src/lib.ts"]);
686        assert_eq!(stats.refreshed_own_files, 1);
687        assert!(store.node_for(Path::new("src/lib.ts"), "after").is_ok());
688    }
689
690    #[test]
691    fn unresolvable_refresh_path_records_a_path_identity_gap() {
692        let temp = tempdir().unwrap();
693        let root = temp.path().join("project");
694        let source = root.join("src/lib.ts");
695        fs::create_dir_all(source.parent().unwrap()).unwrap();
696        fs::write(&source, "export function live() {}\n").unwrap();
697        let foreign = temp.path().join("foreign.ts");
698        fs::write(&foreign, "export function foreign() {}\n").unwrap();
699        let store = CallGraphStore::open(temp.path().join("store"), root.clone()).unwrap();
700        store.cold_build(std::slice::from_ref(&source)).unwrap();
701
702        let error = store.refresh_files(&[foreign.clone()]).unwrap_err();
703        assert!(matches!(
704            error,
705            CallGraphStoreError::PathIdentityMismatch { .. }
706        ));
707        let conn = store.conn.lock().unwrap();
708        assert_eq!(
709            path_identity_mismatch_reason(&conn).unwrap(),
710            Some(format!(
711                "callgraph_path_identity_mismatch path={} project_root={}",
712                foreign.display(),
713                root.display()
714            ))
715        );
716    }
717
718    #[test]
719    fn idle_checkpoint_interval_prevents_checkpoint_storms() {
720        let now = Instant::now();
721        assert!(idle_checkpoint_due(None, now));
722        assert!(!idle_checkpoint_due(
723            Some(now),
724            now + Duration::from_secs(REFRESH_IDLE_CHECKPOINT_INTERVAL.as_secs() - 1),
725        ));
726        assert!(idle_checkpoint_due(
727            Some(now),
728            now + REFRESH_IDLE_CHECKPOINT_INTERVAL,
729        ));
730    }
731
732    #[test]
733    fn write_metrics_decay_after_the_sixty_second_window() {
734        let key = format!("metrics-test-{}", now_nanos());
735        let metrics = callgraph_write_metrics_for_key(&key);
736        metrics.record_commit(17);
737        assert_eq!(metrics.snapshot().commits_60s, 1);
738        assert_eq!(metrics.snapshot().pages_or_bytes_written_60s, 17);
739        metrics.window_start_ms.store(
740            unix_millis_now().saturating_sub(CALLGRAPH_WRITE_METRIC_WINDOW.as_millis() as u64),
741            AtomicOrdering::Release,
742        );
743        assert_eq!(metrics.snapshot(), CallgraphWriteMetricsSnapshot::default());
744    }
745}
746
747#[cfg(test)]
748type ColdBuildBeforePublishObserver = dyn Fn() + Send + Sync + 'static;
749// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
750// thread running the cold build, and the only caller (a test) installs and
751// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
752// across parallel tests — one test's installed observer fired during ANOTHER
753// test's `cold_build_with_lease`, asserting against the wrong build's edges
754// (flaked on Windows CI under parallel scheduling). Production never sets it.
755thread_local! {
756    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
757        const { std::cell::RefCell::new(None) };
758    #[cfg(test)]
759    static COLD_BUILD_BEFORE_PUBLISH_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildBeforePublishObserver>>> =
760        const { std::cell::RefCell::new(None) };
761    #[cfg(test)]
762    static COLD_BUILD_SLICE_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSliceObserver>>> =
763        const { std::cell::RefCell::new(None) };
764    #[cfg(test)]
765    static COLD_BUILD_EXTRACT_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildExtractObserver>>> =
766        const { std::cell::RefCell::new(None) };
767    static MIGRATION_AVAILABLE_DISK_OVERRIDE: std::cell::RefCell<Option<u64>> =
768        const { std::cell::RefCell::new(None) };
769    static MIGRATION_FAIL_AFTER_TEMP_COPY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
770    static MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED: std::cell::Cell<bool> =
771        const { std::cell::Cell::new(false) };
772    static PUBLISH_ADMISSION: std::cell::RefCell<Option<(crate::root_cache::ArtifactPublishEpoch, u64)>> =
773        const { std::cell::RefCell::new(None) };
774    static REFRESH_COMMIT_ADMISSION: std::cell::RefCell<Option<(SubcLifecycleAdmission, Arc<std::sync::atomic::AtomicU64>, u64)>> =
775        const { std::cell::RefCell::new(None) };
776}
777
778mod dead_code_projection;
779pub use dead_code_projection::project_dead_code_snapshot;
780pub(crate) use dead_code_projection::{
781    project_dead_code_snapshot_from_view, project_dead_code_snapshot_incremental_with_costs,
782    project_dead_code_snapshot_with_revision, ProjectionCostEstimates, ProjectionKind,
783    ProjectionVerdict, MAX_DELTA_BYTES,
784};
785#[cfg(test)]
786pub(crate) use dead_code_projection::{
787    project_dead_code_snapshot_incremental, set_projection_before_open_observer,
788    take_projection_work,
789};
790
791#[doc(hidden)]
792pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
793    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
794}
795
796#[cfg(test)]
797fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
798    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
799}
800
801#[cfg(test)]
802fn notify_cold_build_before_publish_observer() {
803    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
804    if let Some(observer) = observer {
805        observer();
806    }
807}
808
809#[cfg(not(test))]
810fn notify_cold_build_before_publish_observer() {}
811
812#[cfg(test)]
813fn set_cold_build_slice_observer(observer: Option<Arc<ColdBuildSliceObserver>>) {
814    COLD_BUILD_SLICE_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
815}
816
817#[cfg(test)]
818fn notify_cold_build_slice_observer(stage: &'static str, completed: usize, total: usize) {
819    let observer = COLD_BUILD_SLICE_OBSERVER.with(|slot| slot.borrow().clone());
820    if let Some(observer) = observer {
821        observer(stage, completed, total);
822    }
823}
824
825#[cfg(not(test))]
826fn notify_cold_build_slice_observer(_stage: &'static str, _completed: usize, _total: usize) {}
827
828#[cfg(test)]
829fn set_cold_build_extract_observer(observer: Option<Arc<ColdBuildExtractObserver>>) {
830    COLD_BUILD_EXTRACT_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
831}
832
833#[cfg(test)]
834fn notify_cold_build_extract_observer(paths: &[PathBuf]) {
835    let observer = COLD_BUILD_EXTRACT_OBSERVER.with(|slot| slot.borrow().clone());
836    if let Some(observer) = observer {
837        observer(paths);
838    }
839}
840
841#[cfg(not(test))]
842fn notify_cold_build_extract_observer(_paths: &[PathBuf]) {}
843
844#[doc(hidden)]
845pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
846    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
847}
848
849#[doc(hidden)]
850pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
851    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
852}
853
854#[doc(hidden)]
855pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
856    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
857}
858
859struct PublishAdmissionGuard {
860    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
861}
862
863impl Drop for PublishAdmissionGuard {
864    fn drop(&mut self) {
865        PUBLISH_ADMISSION.with(|slot| {
866            *slot.borrow_mut() = self.previous.take();
867        });
868    }
869}
870
871pub(crate) fn with_publish_epoch<R>(
872    epoch: crate::root_cache::ArtifactPublishEpoch,
873    expected: u64,
874    run: impl FnOnce() -> R,
875) -> R {
876    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
877    let _guard = PublishAdmissionGuard { previous };
878    run()
879}
880
881fn ensure_cold_build_current(stage: &'static str, completed: usize, total: usize) -> Result<()> {
882    notify_cold_build_slice_observer(stage, completed, total);
883    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
884    if admission.is_none_or(|(epoch, expected)| epoch.is_current(expected)) {
885        if let Some(scope) = crate::logging::current_index_build() {
886            crate::logging::log_index_event(
887                crate::logging::IndexEvent::from_scope(
888                    crate::logging::IndexEventKind::BuildProgress,
889                    &scope,
890                )
891                .field("stage", stage)
892                .field("completed", completed)
893                .field("total", total)
894                .field("elapsed_ms", scope.elapsed_ms()),
895            );
896        }
897        return Ok(());
898    }
899    crate::slog_info!(
900        "callgraph cold build superseded, stopping after {}/{} ({})",
901        completed,
902        total,
903        stage
904    );
905    if let Some(scope) = crate::logging::current_index_build() {
906        crate::logging::log_index_event(
907            crate::logging::IndexEvent::from_scope(
908                crate::logging::IndexEventKind::BuildSuperseded,
909                &scope,
910            )
911            .field("stage", stage)
912            .field("completed", completed)
913            .field("total", total),
914        );
915    }
916    Err(CallGraphStoreError::Superseded)
917}
918
919fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
920    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
921    match admission {
922        Some((epoch, expected)) => epoch
923            .run_if_current(expected, publish)
924            .unwrap_or(Err(CallGraphStoreError::Superseded)),
925        None => publish(),
926    }
927}
928
929struct RefreshCommitAdmissionGuard {
930    previous: Option<(
931        SubcLifecycleAdmission,
932        Arc<std::sync::atomic::AtomicU64>,
933        u64,
934    )>,
935}
936
937impl Drop for RefreshCommitAdmissionGuard {
938    fn drop(&mut self) {
939        REFRESH_COMMIT_ADMISSION.with(|slot| {
940            *slot.borrow_mut() = self.previous.take();
941        });
942    }
943}
944
945fn with_refresh_commit_admission<R>(
946    lifecycle: SubcLifecycleAdmission,
947    generation_flag: Arc<std::sync::atomic::AtomicU64>,
948    expected_generation: u64,
949    run: impl FnOnce() -> R,
950) -> R {
951    let previous = REFRESH_COMMIT_ADMISSION
952        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
953    let _guard = RefreshCommitAdmissionGuard { previous };
954    run()
955}
956
957fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
958    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
959    let commit = || {
960        publish_if_current(|| {
961            tx.commit()?;
962            Ok(())
963        })
964    };
965    match admission {
966        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
967            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
968            .unwrap_or(Err(CallGraphStoreError::Superseded)),
969        None => commit(),
970    }
971}
972
973fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
974    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
975    if let Some(observer) = observer {
976        observer(temp_path, target_path);
977    }
978}
979
980#[derive(Debug)]
981pub enum CallGraphStoreError {
982    Io(std::io::Error),
983    Sqlite(rusqlite::Error),
984    Json(serde_json::Error),
985    Aft(AftError),
986    Lock(crate::fs_lock::AcquireError),
987    MissingCallerData {
988        file: String,
989    },
990    Unavailable(String),
991    PathIdentityMismatch {
992        path: PathBuf,
993        project_root: PathBuf,
994    },
995    Suspended(crate::build_breaker::BuildSuspension),
996    Superseded,
997    StaleFiles(Vec<String>),
998}
999
1000impl CallGraphStoreError {
1001    pub(crate) fn is_transient_lock_contention(&self) -> bool {
1002        matches!(
1003            self,
1004            Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
1005                if matches!(
1006                    error.code,
1007                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
1008                )
1009        )
1010    }
1011}
1012
1013impl fmt::Display for CallGraphStoreError {
1014    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1015        match self {
1016            Self::Io(error) => write!(formatter, "I/O error: {error}"),
1017            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
1018            Self::Json(error) => write!(formatter, "json error: {error}"),
1019            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
1020            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
1021            Self::MissingCallerData { file } => {
1022                write!(formatter, "missing extracted caller data for {file}")
1023            }
1024            Self::Unavailable(message) => {
1025                write!(formatter, "callgraph store unavailable: {message}")
1026            }
1027            Self::PathIdentityMismatch { path, project_root } => write!(
1028                formatter,
1029                "callgraph path identity mismatch: {} is not under project root {}",
1030                path.display(),
1031                project_root.display()
1032            ),
1033            Self::Suspended(suspension) => write!(
1034                formatter,
1035                "callgraph build suspended for {} after {} deaths ({})",
1036                suspension.domain.as_str(),
1037                suspension.death_count,
1038                suspension.reason
1039            ),
1040            Self::Superseded => {
1041                write!(formatter, "callgraph store build superseded before publish")
1042            }
1043            Self::StaleFiles(files) => {
1044                write!(
1045                    formatter,
1046                    "callgraph store has stale files: {}",
1047                    files.join(", ")
1048                )
1049            }
1050        }
1051    }
1052}
1053
1054impl std::error::Error for CallGraphStoreError {}
1055
1056impl From<std::io::Error> for CallGraphStoreError {
1057    fn from(error: std::io::Error) -> Self {
1058        Self::Io(error)
1059    }
1060}
1061
1062impl From<rusqlite::Error> for CallGraphStoreError {
1063    fn from(error: rusqlite::Error) -> Self {
1064        Self::Sqlite(error)
1065    }
1066}
1067
1068impl From<serde_json::Error> for CallGraphStoreError {
1069    fn from(error: serde_json::Error) -> Self {
1070        Self::Json(error)
1071    }
1072}
1073
1074impl From<AftError> for CallGraphStoreError {
1075    fn from(error: AftError) -> Self {
1076        Self::Aft(error)
1077    }
1078}
1079
1080impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
1081    fn from(error: crate::fs_lock::AcquireError) -> Self {
1082        Self::Lock(error)
1083    }
1084}
1085
1086pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
1087
1088/// Config flag name gating whether the store is opened (default on). Production
1089/// commands open it through `open_if_enabled` so the substrate can be disabled
1090/// without code changes.
1091pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
1092
1093#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1094pub struct CallGraphStoreOptions {
1095    pub enabled: bool,
1096}
1097
1098pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
1099
1100/// Shared context state that lets the refresh worker observe a store installed
1101/// after its batch was opened. The worker clones the installed store Arc before
1102/// checking it, so no context lock guard crosses the check or enqueue call.
1103#[derive(Clone)]
1104pub(crate) struct CallgraphRefreshState {
1105    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1106    heavy_root_work_allowed: Arc<AtomicBool>,
1107}
1108
1109impl CallgraphRefreshState {
1110    pub(crate) fn new(
1111        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1112        heavy_root_work_allowed: Arc<AtomicBool>,
1113    ) -> Self {
1114        Self {
1115            store,
1116            heavy_root_work_allowed,
1117        }
1118    }
1119
1120    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
1121        self.store
1122            .read()
1123            .unwrap_or_else(std::sync::PoisonError::into_inner)
1124            .as_ref()
1125            .map(Arc::clone)
1126    }
1127}
1128
1129type WorkspaceCratePrefixes = HashMap<String, String>;
1130
1131#[derive(Clone, Debug, Default)]
1132struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
1133
1134const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
1135
1136pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
1137    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
1138}
1139
1140/// A JS workspace manifest change can add or remove members, so the
1141/// process-wide workspace package cache must be dropped before the refresh
1142/// resolves imports against it. The cache is otherwise cleared only on
1143/// configure, which is what makes it worth having across refreshes.
1144pub(crate) fn invalidates_workspace_package_cache(path: &Path) -> bool {
1145    matches!(
1146        path.file_name().and_then(|name| name.to_str()),
1147        Some("package.json") | Some("pnpm-workspace.yaml")
1148    )
1149}
1150
1151#[derive(Clone, Debug, Hash, PartialEq, Eq)]
1152struct RefreshRoot {
1153    callgraph_dir: PathBuf,
1154    project_root: PathBuf,
1155}
1156
1157#[derive(Clone)]
1158pub(crate) struct CallgraphRefreshTicket {
1159    lifecycle: SubcLifecycleAdmission,
1160    generation_flag: Arc<std::sync::atomic::AtomicU64>,
1161    expected_generation: u64,
1162    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1163    expected_publish_epoch: u64,
1164}
1165
1166impl CallgraphRefreshTicket {
1167    pub(crate) fn new(
1168        lifecycle: SubcLifecycleAdmission,
1169        generation_flag: Arc<std::sync::atomic::AtomicU64>,
1170        expected_generation: u64,
1171        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1172        expected_publish_epoch: u64,
1173    ) -> Self {
1174        Self {
1175            lifecycle,
1176            generation_flag,
1177            expected_generation,
1178            publish_epoch,
1179            expected_publish_epoch,
1180        }
1181    }
1182
1183    fn is_current(&self) -> bool {
1184        self.lifecycle
1185            .is_current(self.generation_flag.as_ref(), self.expected_generation)
1186            && self.publish_epoch.current() == self.expected_publish_epoch
1187    }
1188}
1189
1190#[derive(Clone)]
1191struct RefreshBatch {
1192    root: RefreshRoot,
1193    paths: BTreeSet<PathBuf>,
1194    pending_sinks: Vec<PendingCallGraphStorePaths>,
1195    refresh_states: Vec<CallgraphRefreshState>,
1196    ticket: Option<CallgraphRefreshTicket>,
1197}
1198
1199impl RefreshBatch {
1200    fn defer(&self) {
1201        for sink in &self.pending_sinks {
1202            sink.lock().extend(self.paths.iter().cloned());
1203        }
1204    }
1205
1206    fn defer_after_open_failure(&self) {
1207        self.defer();
1208        if self
1209            .ticket
1210            .as_ref()
1211            .is_some_and(|ticket| !ticket.is_current())
1212            || !self
1213                .refresh_states
1214                .iter()
1215                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1216        {
1217            return;
1218        }
1219
1220        let ready_store_installed = self.refresh_states.iter().any(|state| {
1221            let store = state.installed_store_snapshot();
1222            store.is_some_and(|store| {
1223                store.project_root() == self.root.project_root
1224                    && !store.is_legacy_fallback()
1225                    && store.is_current()
1226            })
1227        });
1228        if !ready_store_installed {
1229            return;
1230        }
1231
1232        // This re-check and the ready-store install's pending-sink take form a
1233        // check-then-act handoff: after this defer, exactly one site observes
1234        // the parked paths with a ready current store, so no polling is needed.
1235        for sink in &self.pending_sinks {
1236            let paths = {
1237                let mut pending = sink.lock();
1238                self.paths
1239                    .iter()
1240                    .filter(|path| pending.remove(*path))
1241                    .cloned()
1242                    .collect::<Vec<_>>()
1243            };
1244            if paths.is_empty() {
1245                continue;
1246            }
1247            let _ = enqueue_callgraph_store_refresh_inner(
1248                self.root.callgraph_dir.clone(),
1249                self.root.project_root.clone(),
1250                paths,
1251                Arc::clone(sink),
1252                self.refresh_states.clone(),
1253                self.ticket.clone(),
1254            );
1255        }
1256    }
1257
1258    fn merge(
1259        &mut self,
1260        paths: impl IntoIterator<Item = PathBuf>,
1261        sink: PendingCallGraphStorePaths,
1262        refresh_states: Vec<CallgraphRefreshState>,
1263        ticket: Option<CallgraphRefreshTicket>,
1264    ) {
1265        self.paths.extend(paths);
1266        if ticket.is_some() {
1267            self.ticket = ticket;
1268        }
1269        if !self
1270            .pending_sinks
1271            .iter()
1272            .any(|existing| Arc::ptr_eq(existing, &sink))
1273        {
1274            self.pending_sinks.push(sink);
1275        }
1276        for refresh_state in refresh_states {
1277            if !self.refresh_states.iter().any(|existing| {
1278                Arc::ptr_eq(&existing.store, &refresh_state.store)
1279                    && Arc::ptr_eq(
1280                        &existing.heavy_root_work_allowed,
1281                        &refresh_state.heavy_root_work_allowed,
1282                    )
1283            }) {
1284                self.refresh_states.push(refresh_state);
1285            }
1286        }
1287    }
1288}
1289
1290#[derive(Default)]
1291struct RefreshQueue {
1292    order: VecDeque<RefreshRoot>,
1293    queued: HashMap<RefreshRoot, RefreshBatch>,
1294    active: Option<RefreshBatch>,
1295    shutdown_requested: bool,
1296}
1297
1298struct RefreshWorkerShared {
1299    queue: Mutex<RefreshQueue>,
1300    wake: Condvar,
1301}
1302
1303struct RefreshWorker {
1304    shared: Arc<RefreshWorkerShared>,
1305    thread: Mutex<Option<JoinHandle<()>>>,
1306}
1307
1308struct RefreshWorkerWatchdog {
1309    first_path: PathBuf,
1310    batch_len: usize,
1311    started: Instant,
1312}
1313
1314impl RefreshWorkerWatchdog {
1315    fn start(paths: &[PathBuf]) -> Self {
1316        Self {
1317            first_path: paths
1318                .first()
1319                .expect("non-empty callgraph refresh batch has a first path")
1320                .clone(),
1321            batch_len: paths.len(),
1322            started: Instant::now(),
1323        }
1324    }
1325}
1326
1327impl Drop for RefreshWorkerWatchdog {
1328    fn drop(&mut self) {
1329        let elapsed = self.started.elapsed();
1330        if elapsed < REFRESH_WORKER_WARN_AFTER {
1331            return;
1332        }
1333        let path = if self.batch_len == 1 {
1334            self.first_path.display().to_string()
1335        } else {
1336            format!(
1337                "{} (+{} paths)",
1338                self.first_path.display(),
1339                self.batch_len - 1
1340            )
1341        };
1342        log::warn!(
1343            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1344            path,
1345            elapsed.as_millis()
1346        );
1347        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1348            log::warn!(
1349                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1350                path,
1351                elapsed.as_millis()
1352            );
1353        }
1354    }
1355}
1356
1357impl RefreshWorker {
1358    fn spawn() -> Arc<Self> {
1359        let shared = Arc::new(RefreshWorkerShared {
1360            queue: Mutex::new(RefreshQueue::default()),
1361            wake: Condvar::new(),
1362        });
1363        let thread_shared = Arc::clone(&shared);
1364        let thread = std::thread::Builder::new()
1365            .name("aft-callgraph-refresh".to_string())
1366            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1367            .expect("failed to spawn callgraph refresh worker");
1368        Arc::new(Self {
1369            shared,
1370            thread: Mutex::new(Some(thread)),
1371        })
1372    }
1373
1374    fn enqueue(
1375        &self,
1376        root: RefreshRoot,
1377        paths: Vec<PathBuf>,
1378        pending_sink: PendingCallGraphStorePaths,
1379        refresh_states: Vec<CallgraphRefreshState>,
1380        ticket: Option<CallgraphRefreshTicket>,
1381    ) -> bool {
1382        let mut queue = self
1383            .shared
1384            .queue
1385            .lock()
1386            .expect("callgraph refresh queue mutex poisoned");
1387        if queue.shutdown_requested {
1388            pending_sink.lock().extend(paths);
1389            return false;
1390        }
1391        if let Some(batch) = queue.queued.get_mut(&root) {
1392            batch.merge(paths, pending_sink, refresh_states, ticket);
1393        } else {
1394            queue.order.push_back(root.clone());
1395            queue.queued.insert(
1396                root.clone(),
1397                RefreshBatch {
1398                    root,
1399                    paths: paths.into_iter().collect(),
1400                    pending_sinks: vec![pending_sink],
1401                    refresh_states,
1402                    ticket,
1403                },
1404            );
1405        }
1406        self.shared.wake.notify_one();
1407        true
1408    }
1409
1410    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1411        let deadline = Instant::now() + budget;
1412        let mut queue = self
1413            .shared
1414            .queue
1415            .lock()
1416            .expect("callgraph refresh queue mutex poisoned");
1417        queue.shutdown_requested = true;
1418        self.shared.wake.notify_one();
1419        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1420            let remaining = deadline.saturating_duration_since(Instant::now());
1421            let (next, _) = self
1422                .shared
1423                .wake
1424                .wait_timeout(queue, remaining)
1425                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1426            queue = next;
1427        }
1428        let drained = queue.active.is_none() && queue.order.is_empty();
1429        if !drained {
1430            if let Some(active) = queue.active.as_ref() {
1431                active.defer();
1432            }
1433            for batch in queue.queued.values() {
1434                batch.defer();
1435            }
1436            queue.order.clear();
1437            queue.queued.clear();
1438        }
1439        drop(queue);
1440
1441        if drained {
1442            if let Some(thread) = self
1443                .thread
1444                .lock()
1445                .expect("callgraph refresh worker thread mutex poisoned")
1446                .take()
1447            {
1448                let _ = thread.join();
1449            }
1450        }
1451        drained
1452    }
1453}
1454
1455static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1456
1457pub fn enqueue_callgraph_store_refresh(
1458    callgraph_dir: PathBuf,
1459    project_root: PathBuf,
1460    paths: Vec<PathBuf>,
1461    pending_sink: PendingCallGraphStorePaths,
1462) -> bool {
1463    enqueue_callgraph_store_refresh_inner(
1464        callgraph_dir,
1465        project_root,
1466        paths,
1467        pending_sink,
1468        Vec::new(),
1469        None,
1470    )
1471}
1472
1473#[cfg(test)]
1474pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1475    callgraph_dir: PathBuf,
1476    project_root: PathBuf,
1477    paths: Vec<PathBuf>,
1478    pending_sink: PendingCallGraphStorePaths,
1479    ticket: CallgraphRefreshTicket,
1480) -> bool {
1481    enqueue_callgraph_store_refresh_inner(
1482        callgraph_dir,
1483        project_root,
1484        paths,
1485        pending_sink,
1486        Vec::new(),
1487        Some(ticket),
1488    )
1489}
1490
1491pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1492    callgraph_dir: PathBuf,
1493    project_root: PathBuf,
1494    paths: Vec<PathBuf>,
1495    pending_sink: PendingCallGraphStorePaths,
1496    refresh_state: CallgraphRefreshState,
1497    ticket: CallgraphRefreshTicket,
1498) -> bool {
1499    enqueue_callgraph_store_refresh_inner(
1500        callgraph_dir,
1501        project_root,
1502        paths,
1503        pending_sink,
1504        vec![refresh_state],
1505        Some(ticket),
1506    )
1507}
1508
1509fn enqueue_callgraph_store_refresh_inner(
1510    callgraph_dir: PathBuf,
1511    project_root: PathBuf,
1512    paths: Vec<PathBuf>,
1513    pending_sink: PendingCallGraphStorePaths,
1514    refresh_states: Vec<CallgraphRefreshState>,
1515    ticket: Option<CallgraphRefreshTicket>,
1516) -> bool {
1517    if paths.is_empty() {
1518        return true;
1519    }
1520    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1521    let worker = {
1522        let mut worker = slot
1523            .lock()
1524            .expect("callgraph refresh worker mutex poisoned");
1525        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1526    };
1527    worker.enqueue(
1528        RefreshRoot {
1529            callgraph_dir,
1530            project_root,
1531        },
1532        paths,
1533        pending_sink,
1534        refresh_states,
1535        ticket,
1536    )
1537}
1538
1539pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1540    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1541}
1542
1543#[doc(hidden)]
1544pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1545    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1546    let worker = slot
1547        .lock()
1548        .expect("callgraph refresh worker mutex poisoned")
1549        .clone();
1550    let Some(worker) = worker else {
1551        return true;
1552    };
1553    let drained = worker.shutdown_with_budget(budget);
1554    if drained {
1555        let mut current = slot
1556            .lock()
1557            .expect("callgraph refresh worker mutex poisoned");
1558        if current
1559            .as_ref()
1560            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1561        {
1562            *current = None;
1563        }
1564    }
1565    drained
1566}
1567
1568fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1569    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1570}
1571
1572fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1573    // The worker owns these caches so maps are shared only by refreshes for the
1574    // same canonical root and disappear when the worker shuts down.
1575    let mut workspace_crate_prefixes = HashMap::new();
1576    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1577    loop {
1578        let batch = {
1579            let mut queue = shared
1580                .queue
1581                .lock()
1582                .expect("callgraph refresh queue mutex poisoned");
1583            loop {
1584                if let Some(root) = queue.order.pop_front() {
1585                    let batch = queue
1586                        .queued
1587                        .remove(&root)
1588                        .expect("queued callgraph refresh root has a batch");
1589                    queue.active = Some(batch.clone());
1590                    break batch;
1591                }
1592                if queue.shutdown_requested {
1593                    return;
1594                }
1595                queue = shared
1596                    .wake
1597                    .wait(queue)
1598                    .expect("callgraph refresh queue mutex poisoned while waiting");
1599            }
1600        };
1601
1602        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1603
1604        let mut queue = shared
1605            .queue
1606            .lock()
1607            .expect("callgraph refresh queue mutex poisoned");
1608        queue.active = None;
1609        let became_idle = queue.order.is_empty();
1610        shared.wake.notify_all();
1611        drop(queue);
1612
1613        if became_idle {
1614            let checkpoint_due = idle_checkpoint_due(
1615                last_idle_checkpoints.get(&batch.root).copied(),
1616                Instant::now(),
1617            );
1618            if checkpoint_due {
1619                if let Some(store) = store {
1620                    if store.checkpoint_wal_truncate() {
1621                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1622                    }
1623                }
1624            }
1625        }
1626    }
1627}
1628
1629fn process_callgraph_refresh_batch(
1630    batch: &RefreshBatch,
1631    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1632) -> Option<CallGraphStore> {
1633    // A manifest event is an invalidation signal, not a source file to parse.
1634    // Drop the root's map even for a superseded batch: the filesystem changed,
1635    // and a later configure must never inherit crate membership from before it.
1636    if batch
1637        .paths
1638        .iter()
1639        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1640    {
1641        workspace_crate_prefixes.remove(&batch.root);
1642    }
1643    if batch
1644        .paths
1645        .iter()
1646        .any(|path| invalidates_workspace_package_cache(path))
1647    {
1648        callgraph::clear_workspace_package_cache();
1649    }
1650
1651    let paths = batch
1652        .paths
1653        .iter()
1654        .filter(|path| crate::parser::detect_language(path).is_some())
1655        .cloned()
1656        .collect::<Vec<_>>();
1657    if paths.is_empty() {
1658        return None;
1659    }
1660    note_refresh_worker_batch_for_test(&batch.root.project_root, &paths);
1661    if batch
1662        .ticket
1663        .as_ref()
1664        .is_some_and(|ticket| !ticket.is_current())
1665    {
1666        // Superseded before starting: park the paths so the next configure's
1667        // pending replay (or unbind cleanup) decides their fate.
1668        batch.defer();
1669        return None;
1670    }
1671    let workspace_crate_prefix_cache =
1672        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1673    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1674    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1675    note_refresh_worker_call_for_test(&batch.root.project_root);
1676    let opened = if test_seam.fail_open {
1677        Ok(None)
1678    } else {
1679        CallGraphStore::open_ready(
1680            batch.root.callgraph_dir.clone(),
1681            batch.root.project_root.clone(),
1682        )
1683    };
1684    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1685        // The gate is deliberately after open_ready so tests can hold a failed
1686        // open between its result and the defer that parks the batch.
1687        let _ = gate.held_tx.send(());
1688        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1689    }
1690    let store = match opened {
1691        Ok(Some(store)) => store,
1692        Ok(None) => {
1693            batch.defer_after_open_failure();
1694            return None;
1695        }
1696        Err(error) => {
1697            batch.defer_after_open_failure();
1698            crate::slog_warn!(
1699                "callgraph store writer open failed during refresh; deferred paths: {}",
1700                error
1701            );
1702            return None;
1703        }
1704    };
1705    if !test_seam.delay.is_zero() {
1706        std::thread::sleep(test_seam.delay);
1707    }
1708    if batch
1709        .ticket
1710        .as_ref()
1711        .is_some_and(|ticket| !ticket.is_current())
1712    {
1713        // This is a superseded-ticket defer, not an open-failure defer: leave
1714        // the paths for the replacement configure instead of self-replaying.
1715        batch.defer();
1716        return Some(store);
1717    }
1718    let refresh_result = if test_seam.fail_refresh {
1719        Err(CallGraphStoreError::Unavailable(
1720            "injected refresh worker failure".to_string(),
1721        ))
1722    } else if let Some(ticket) = &batch.ticket {
1723        with_publish_epoch(
1724            ticket.publish_epoch.clone(),
1725            ticket.expected_publish_epoch,
1726            || {
1727                with_refresh_commit_admission(
1728                    ticket.lifecycle.clone(),
1729                    Arc::clone(&ticket.generation_flag),
1730                    ticket.expected_generation,
1731                    || {
1732                        store
1733                            .refresh_files_with_workspace_crate_prefix_cache(
1734                                &paths,
1735                                workspace_crate_prefix_cache.clone(),
1736                            )
1737                            .map(|_| ())
1738                    },
1739                )
1740            },
1741        )
1742    } else {
1743        store
1744            .refresh_files_with_workspace_crate_prefix_cache(
1745                &paths,
1746                workspace_crate_prefix_cache.clone(),
1747            )
1748            .map(|_| ())
1749    };
1750    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1751        // The commit lost the fence race: a newer configure or publication
1752        // owns the store now. Defer instead of stale-marking — the paths were
1753        // never committed, and the replacement generation re-indexes them.
1754        batch.defer();
1755        return Some(store);
1756    }
1757    if let Err(error) = refresh_result {
1758        crate::slog_warn!("callgraph store refresh failed: {}", error);
1759        match store.mark_files_stale(&paths) {
1760            Ok(marked) => {
1761                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1762                crate::slog_warn!(
1763                    "marked {} callgraph store file(s) stale after refresh failure",
1764                    marked.len()
1765                );
1766            }
1767            Err(mark_error) => crate::slog_warn!(
1768                "failed to mark callgraph store files stale after refresh failure: {}",
1769                mark_error
1770            ),
1771        }
1772    } else {
1773        crate::logging::note_callgraph_invalidations(paths.len());
1774    }
1775    Some(store)
1776}
1777
1778fn workspace_crate_prefix_cache_for_root(
1779    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1780    root: &RefreshRoot,
1781) -> WorkspaceCratePrefixCache {
1782    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1783        // Eviction only costs a future rebuild; it cannot make resolution stale.
1784        if let Some(evicted) = caches.keys().next().cloned() {
1785            caches.remove(&evicted);
1786        }
1787    }
1788    caches.entry(root.clone()).or_default().clone()
1789}
1790
1791#[derive(Clone, Default)]
1792struct RefreshWorkerTestSeam {
1793    delay: Duration,
1794    fail_refresh: bool,
1795    fail_open: bool,
1796    refresh_calls: usize,
1797    worker_calls: usize,
1798    stale_marks: usize,
1799    received_paths: BTreeSet<PathBuf>,
1800}
1801
1802static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1803    OnceLock::new();
1804
1805struct RefreshWorkerTestGate {
1806    held_tx: crossbeam_channel::Sender<()>,
1807    release_rx: crossbeam_channel::Receiver<()>,
1808}
1809
1810static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1811    OnceLock::new();
1812
1813#[doc(hidden)]
1814pub fn install_callgraph_refresh_worker_test_gate(
1815    project_root: PathBuf,
1816) -> (
1817    crossbeam_channel::Receiver<()>,
1818    crossbeam_channel::Sender<()>,
1819) {
1820    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1821    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1822    REFRESH_WORKER_TEST_GATES
1823        .get_or_init(|| Mutex::new(HashMap::new()))
1824        .lock()
1825        .expect("callgraph refresh test gate mutex poisoned")
1826        .insert(
1827            project_root,
1828            RefreshWorkerTestGate {
1829                held_tx,
1830                release_rx,
1831            },
1832        );
1833    (held_rx, release_tx)
1834}
1835
1836fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1837    REFRESH_WORKER_TEST_GATES
1838        .get_or_init(|| Mutex::new(HashMap::new()))
1839        .lock()
1840        .expect("callgraph refresh test gate mutex poisoned")
1841        .remove(project_root)
1842}
1843
1844fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1845    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1846        return RefreshWorkerTestSeam::default();
1847    };
1848    seams
1849        .lock()
1850        .expect("callgraph refresh test seam mutex poisoned")
1851        .get(project_root)
1852        .cloned()
1853        .unwrap_or_default()
1854}
1855
1856fn note_refresh_worker_batch_for_test(project_root: &Path, paths: &[PathBuf]) {
1857    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1858        if let Some(seam) = seams
1859            .lock()
1860            .expect("callgraph refresh test seam mutex poisoned")
1861            .get_mut(project_root)
1862        {
1863            seam.worker_calls += 1;
1864            seam.received_paths.extend(paths.iter().cloned());
1865        }
1866    }
1867}
1868
1869fn note_refresh_worker_call_for_test(project_root: &Path) {
1870    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1871        if let Some(seam) = seams
1872            .lock()
1873            .expect("callgraph refresh test seam mutex poisoned")
1874            .get_mut(project_root)
1875        {
1876            seam.refresh_calls += 1;
1877        }
1878    }
1879}
1880
1881fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1882    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1883        if let Some(seam) = seams
1884            .lock()
1885            .expect("callgraph refresh test seam mutex poisoned")
1886            .get_mut(project_root)
1887        {
1888            seam.stale_marks += 1;
1889        }
1890    }
1891}
1892
1893#[doc(hidden)]
1894pub fn set_callgraph_refresh_worker_test_seam(
1895    project_root: PathBuf,
1896    delay: Duration,
1897    fail_refresh: bool,
1898) {
1899    REFRESH_WORKER_TEST_SEAMS
1900        .get_or_init(|| Mutex::new(HashMap::new()))
1901        .lock()
1902        .expect("callgraph refresh test seam mutex poisoned")
1903        .insert(
1904            project_root,
1905            RefreshWorkerTestSeam {
1906                delay,
1907                fail_refresh,
1908                ..RefreshWorkerTestSeam::default()
1909            },
1910        );
1911}
1912
1913#[doc(hidden)]
1914pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1915    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1916        if let Some(seam) = seams
1917            .lock()
1918            .expect("callgraph refresh test seam mutex poisoned")
1919            .get_mut(&project_root)
1920        {
1921            seam.fail_open = enabled;
1922        }
1923    }
1924}
1925
1926#[doc(hidden)]
1927pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1928    let seam = refresh_worker_test_seam(project_root);
1929    (seam.refresh_calls, seam.stale_marks)
1930}
1931
1932#[doc(hidden)]
1933pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1934    refresh_worker_test_seam(project_root).worker_calls
1935}
1936
1937#[doc(hidden)]
1938pub fn callgraph_refresh_worker_test_paths(project_root: &Path) -> BTreeSet<PathBuf> {
1939    refresh_worker_test_seam(project_root).received_paths
1940}
1941
1942#[doc(hidden)]
1943pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1944    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1945        seams
1946            .lock()
1947            .expect("callgraph refresh test seam mutex poisoned")
1948            .remove(project_root);
1949    }
1950}
1951
1952#[derive(Debug)]
1953pub struct CallGraphStore {
1954    project_root: PathBuf,
1955    project_key: String,
1956    /// The concrete on-disk DB file this store opened. With the generation
1957    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1958    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1959    sqlite_path: PathBuf,
1960    /// Root-keyed directory whose pointer controls this store. For a legacy
1961    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1962    /// newly published root-keyed generation invalidates the fallback reader.
1963    publication_dir: PathBuf,
1964    /// True only when the root-keyed read path opened data from a legacy
1965    /// harness partition. Writer-capable callers use this to schedule migration
1966    /// without making read-only/worktree callers acquire a writer lease.
1967    legacy_fallback: bool,
1968    manifest_view: bool,
1969    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1970    /// or `None` when it opened the legacy single-file DB. Used to detect when
1971    /// another process has published a newer generation so this process can
1972    /// drop its connection and reopen (see `current_generation`).
1973    generation: Option<String>,
1974    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1975    read_marker: Option<crate::root_cache::ReadMarker>,
1976    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1977    // Failed validations are not cached, so a later successful build remains visible.
1978    database_ready: AtomicBool,
1979    write_metrics: Arc<CallgraphWriteMetrics>,
1980    conn: Mutex<TrackedConnection>,
1981}
1982
1983#[derive(Debug)]
1984pub struct ReadonlyCallGraphStore {
1985    inner: CallGraphStore,
1986    _view_pin: Option<Arc<crate::pins::QueryPin>>,
1987}
1988
1989pub trait CallGraphRead {
1990    fn project_root(&self) -> &Path;
1991    fn project_key(&self) -> &str;
1992    fn sqlite_path(&self) -> &Path;
1993    fn is_current(&self) -> bool;
1994    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1995    fn indexed_file_count(&self) -> Result<usize>;
1996    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1997    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1998    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1999    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
2000    fn direct_callers_for_symbols(
2001        &self,
2002        targets: &[(String, String)],
2003    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
2004        targets
2005            .iter()
2006            .cloned()
2007            .map(|target| {
2008                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
2009                Ok((target, callers))
2010            })
2011            .collect()
2012    }
2013    fn direct_caller_counts_of(
2014        &self,
2015        targets: &[(String, String)],
2016    ) -> Result<HashMap<(String, String), usize>>;
2017    fn outgoing_calls_for_symbols(
2018        &self,
2019        sources: &[(String, String)],
2020    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
2021    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
2022        -> Result<StoreCallersResult>;
2023    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
2024    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2025    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2026    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
2027    fn call_tree(
2028        &self,
2029        file_rel: &Path,
2030        symbol: &str,
2031        depth: usize,
2032    ) -> Result<callgraph::CallTreeNode>;
2033    fn trace_to(
2034        &self,
2035        file_rel: &Path,
2036        symbol: &str,
2037        max_depth: usize,
2038    ) -> Result<callgraph::TraceToResult>;
2039    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
2040    fn trace_to_symbol(
2041        &self,
2042        file_rel: &Path,
2043        symbol: &str,
2044        to_symbol: &str,
2045        to_file: Option<&Path>,
2046        max_depth: usize,
2047    ) -> Result<callgraph::TraceToSymbolResult>;
2048}
2049
2050#[derive(Debug, Clone, PartialEq, Eq)]
2051enum OpenRootRepair {
2052    None,
2053    ReRooted,
2054    NeedsRebuild {
2055        previous_roots: Vec<String>,
2056        current_root: String,
2057        reason: String,
2058    },
2059}
2060
2061struct OpenedStore {
2062    store: CallGraphStore,
2063    root_repair: OpenRootRepair,
2064}
2065
2066#[derive(Clone, Debug)]
2067struct LegacyCallgraphPartition {
2068    harness: String,
2069    dir: PathBuf,
2070    key: String,
2071    bytes: u64,
2072    freshness: Option<SystemTime>,
2073}
2074
2075#[derive(Clone, Debug)]
2076struct LegacyCallgraphTarget {
2077    partition: LegacyCallgraphPartition,
2078    sqlite_path: PathBuf,
2079    generation: Option<String>,
2080    source_bytes: u64,
2081    source_blake3: String,
2082}
2083
2084#[derive(Clone, Debug)]
2085struct SourceFingerprint {
2086    bytes: u64,
2087    blake3: String,
2088}
2089
2090#[derive(Clone, Debug)]
2091struct PublishedLegacyMigration {
2092    generation: String,
2093    migrated_bytes: u64,
2094}
2095
2096#[derive(Debug, Clone)]
2097pub struct ColdBuildStats {
2098    pub files: usize,
2099    pub nodes: usize,
2100    pub refs: usize,
2101    pub edges: usize,
2102    pub failed_files: Vec<String>,
2103    pub elapsed_ms: u128,
2104}
2105
2106#[derive(Debug, Clone)]
2107pub struct IncrementalStats {
2108    pub changed_files: Vec<String>,
2109    pub surface_changed: Vec<String>,
2110    pub deleted_files: Vec<String>,
2111    pub dependency_selected_refs: usize,
2112    pub refreshed_own_files: usize,
2113    pub unchanged_extract_files: usize,
2114}
2115
2116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2117pub struct StalePathCensus {
2118    pub stale: usize,
2119    pub absent: usize,
2120    pub unreadable: usize,
2121}
2122
2123/// Phase timings for the copy-based incremental refresh benchmark.
2124#[doc(hidden)]
2125#[derive(Debug, Clone, Default, PartialEq, Eq)]
2126pub struct RefreshFilesProfile {
2127    pub parse: Duration,
2128    pub dependency_selection: Duration,
2129    pub row_deletes: Duration,
2130    pub row_inserts: Duration,
2131    pub dependent_parse: Duration,
2132    pub index_load: Duration,
2133    pub index_loads: usize,
2134    pub ref_resolution: Duration,
2135    pub method_dispatch: Duration,
2136    pub commit: Duration,
2137    pub total: Duration,
2138}
2139
2140impl RefreshFilesProfile {
2141    pub fn report(&self) -> String {
2142        format!(
2143            "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",
2144            self.parse.as_millis(),
2145            self.dependency_selection.as_millis(),
2146            self.row_deletes.as_millis(),
2147            self.row_inserts.as_millis(),
2148            self.dependent_parse.as_millis(),
2149            self.index_load.as_millis(),
2150            self.ref_resolution.as_millis(),
2151            self.method_dispatch.as_millis(),
2152            self.commit.as_millis(),
2153            self.total.as_millis(),
2154        )
2155    }
2156}
2157
2158#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2159pub struct StoredEdge {
2160    pub source_file: String,
2161    pub source_symbol: String,
2162    pub target_file: String,
2163    pub target_symbol: String,
2164    pub kind: String,
2165    pub line: u32,
2166}
2167
2168#[derive(Debug, Clone, PartialEq, Eq)]
2169pub struct StoreNode {
2170    node_id: String,
2171    pub file: String,
2172    pub symbol: String,
2173    pub name: String,
2174    pub kind: String,
2175    pub line: u32,
2176    pub end_line: u32,
2177    pub signature: Option<String>,
2178    pub exported: bool,
2179    pub is_entry_point: bool,
2180    pub lang: LangId,
2181}
2182
2183#[cfg(test)]
2184impl StoreNode {
2185    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
2186        Self {
2187            node_id: format!("{file}:{symbol}"),
2188            file: file.to_string(),
2189            symbol: symbol.to_string(),
2190            name: symbol.to_string(),
2191            kind: "function".to_string(),
2192            line: 1,
2193            end_line: 1,
2194            signature: None,
2195            exported: is_entry_point,
2196            is_entry_point,
2197            lang: LangId::TypeScript,
2198        }
2199    }
2200}
2201
2202#[derive(Debug, Clone, PartialEq, Eq)]
2203pub struct StoreCallSite {
2204    pub caller: StoreNode,
2205    pub target_file: String,
2206    pub target_symbol: String,
2207    pub target: Option<StoreNode>,
2208    pub line: u32,
2209    pub byte_start: usize,
2210    pub byte_end: usize,
2211    pub resolved: bool,
2212    pub provenance: String,
2213}
2214
2215impl StoreCallSite {
2216    pub fn approximate(&self) -> bool {
2217        self.provenance == PROVENANCE_NAME_MATCH
2218    }
2219
2220    pub fn resolved_by(&self) -> &str {
2221        &self.provenance
2222    }
2223
2224    pub fn supplemental_resolution(&self) -> Option<&str> {
2225        match self.provenance.as_str() {
2226            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2227            _ => None,
2228        }
2229    }
2230}
2231
2232#[derive(Debug, Clone, PartialEq, Eq)]
2233pub struct StoreUnresolvedCall {
2234    pub caller: StoreNode,
2235    pub symbol: String,
2236    pub full_ref: Option<String>,
2237    pub line: u32,
2238    pub byte_start: usize,
2239    pub byte_end: usize,
2240}
2241
2242#[derive(Debug, Clone, PartialEq, Eq)]
2243pub struct StoreCallersResult {
2244    pub target: StoreNode,
2245    pub callers: Vec<StoreCallSite>,
2246    pub scanned_files: usize,
2247    pub depth_limited: bool,
2248    pub truncated: usize,
2249}
2250
2251#[derive(Debug, Clone, PartialEq, Eq)]
2252pub struct StoreImpactCaller {
2253    pub site: StoreCallSite,
2254    pub signature: Option<String>,
2255    pub is_entry_point: bool,
2256    pub call_expression: Option<String>,
2257    pub parameters: Vec<String>,
2258}
2259
2260#[derive(Debug, Clone, PartialEq, Eq)]
2261pub struct StoreImpactResult {
2262    pub target: StoreNode,
2263    pub parameters: Vec<String>,
2264    pub callers: Vec<StoreImpactCaller>,
2265    pub depth_limited: bool,
2266    pub truncated: usize,
2267}
2268
2269#[derive(Debug, Clone)]
2270struct ExtractFailure {
2271    rel_path: String,
2272    freshness: Option<FileFreshness>,
2273}
2274
2275#[derive(Debug, Clone)]
2276struct BuildExtractsResult {
2277    extracts: Vec<FileExtract>,
2278    failures: Vec<ExtractFailure>,
2279}
2280
2281#[derive(Debug, Clone)]
2282enum StoreForwardCall {
2283    Resolved(StoreCallSite),
2284    Unresolved(StoreUnresolvedCall),
2285}
2286
2287impl StoreForwardCall {
2288    fn byte_start(&self) -> usize {
2289        match self {
2290            Self::Resolved(site) => site.byte_start,
2291            Self::Unresolved(call) => call.byte_start,
2292        }
2293    }
2294
2295    fn line(&self) -> u32 {
2296        match self {
2297            Self::Resolved(site) => site.line,
2298            Self::Unresolved(call) => call.line,
2299        }
2300    }
2301}
2302
2303#[derive(Debug, Clone)]
2304struct FileExtract {
2305    rel_path: String,
2306    freshness: FileFreshness,
2307    lang: LangId,
2308    data: FileCallData,
2309    nodes: Vec<NodeRecord>,
2310    raw_refs: Vec<RawRef>,
2311    dispatch_hints: Vec<DispatchHint>,
2312    surface_fingerprint: String,
2313}
2314
2315#[derive(Debug, Clone)]
2316struct NodeRecord {
2317    id: String,
2318    file_path: String,
2319    name: String,
2320    scoped_name: String,
2321    kind: String,
2322    range: Range,
2323    range_ordinal: u32,
2324    signature: Option<String>,
2325    exported: bool,
2326    is_default_export: bool,
2327    is_type_like: bool,
2328    is_callgraph_entry_point: bool,
2329}
2330
2331#[derive(Debug, Clone)]
2332struct RawRef {
2333    ref_id: String,
2334    caller_node: Option<String>,
2335    caller_symbol: Option<String>,
2336    caller_file: String,
2337    kind: String,
2338    short_name: Option<String>,
2339    full_ref: Option<String>,
2340    module_path: Option<String>,
2341    import_kind: Option<String>,
2342    local_name: Option<String>,
2343    requested_name: Option<String>,
2344    namespace_alias: Option<String>,
2345    wildcard: bool,
2346    line: u32,
2347    byte_start: usize,
2348    byte_end: usize,
2349    dependencies: BTreeSet<String>,
2350}
2351
2352/// A raw reference read from the durable staging table with its SQLite ordering
2353/// key. The ordering key is advanced only in the same transaction that writes
2354/// the resolved result, so a crash resumes at a committed window boundary.
2355#[derive(Debug)]
2356struct StagedRef {
2357    rowid: u64,
2358    raw: RawRef,
2359}
2360
2361#[derive(Debug, Clone)]
2362struct ResolvedRef {
2363    raw: RawRef,
2364    status: String,
2365    target_node: Option<String>,
2366    target_file: Option<String>,
2367    target_symbol: Option<String>,
2368    dependencies: BTreeSet<String>,
2369    edge: Option<EdgeRecord>,
2370}
2371
2372#[derive(Debug, Clone)]
2373struct EdgeRecord {
2374    edge_id: String,
2375    source_node: String,
2376    target_node: Option<String>,
2377    target_file: String,
2378    target_symbol: String,
2379    kind: String,
2380    line: u32,
2381}
2382
2383#[derive(Debug, Clone)]
2384struct DispatchHint {
2385    id: String,
2386    method_name: String,
2387    caller_node: String,
2388    file: String,
2389    line: u32,
2390    byte_start: usize,
2391    byte_end: usize,
2392}
2393
2394#[derive(Debug, Clone)]
2395struct NameMatchRef {
2396    ref_id: String,
2397    caller_node: String,
2398    caller_file: String,
2399    caller_symbol: String,
2400    caller_signature: Option<String>,
2401    receiver_expression: String,
2402    receiver: String,
2403    method_name: String,
2404    colon_dispatch: bool,
2405    line: u32,
2406    lang: String,
2407}
2408
2409#[derive(Debug, Clone)]
2410struct NameMatchCandidate {
2411    node_id: String,
2412    file_path: String,
2413    scoped_name: String,
2414    kind: String,
2415    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2416    start_line: u32,
2417}
2418
2419#[derive(Debug, Clone)]
2420struct FileRow {
2421    surface_fingerprint: String,
2422    freshness: FileFreshness,
2423}
2424
2425#[derive(Debug, Clone)]
2426struct DbFileIndex {
2427    lang: Option<LangId>,
2428    exports: HashSet<String>,
2429    default_export: Option<String>,
2430    export_aliases: HashMap<String, String>,
2431    node_by_scoped: HashMap<String, String>,
2432    node_by_bare: HashMap<String, String>,
2433    node_kind_by_id: HashMap<String, String>,
2434    module_targets: HashMap<String, Option<String>>,
2435    declared_module_targets: HashMap<String, Option<String>>,
2436    reexports: Vec<ReexportIndex>,
2437}
2438
2439#[derive(Debug, Clone)]
2440struct ReexportIndex {
2441    target_file: Option<String>,
2442    named: HashMap<String, String>,
2443    wildcard: bool,
2444}
2445
2446#[derive(Clone)]
2447struct ProjectIndex<'a> {
2448    facts: Rc<dyn ProjectFacts + 'a>,
2449    unbound_non_utf8_paths: Vec<Vec<u8>>,
2450    project_root: PathBuf,
2451    files: HashMap<String, DbFileIndex>,
2452    caller_data: HashMap<String, &'a FileCallData>,
2453    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2454    /// watcher events replace the cache before another batch can resolve refs.
2455    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2456    /// its own workspace mapping.
2457    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2458    rust_crate_roots: callgraph::RustCrateRootMemo,
2459}
2460
2461/// Resolution reads symbols and exports through one interface. Incremental
2462/// refreshes use the in-memory index, while cold builds query only the rows
2463/// needed by the active caller from SQLite.
2464trait ResolverIndex {
2465    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2466    fn lang_for(&self, file: &str) -> Option<LangId>;
2467    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2468    fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2469    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2470    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2471    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2472    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2473    fn has_export(&self, file: &str, symbol: &str) -> bool;
2474    fn default_export(&self, file: &str) -> Option<String>;
2475    fn contains_file(&self, file: &str) -> bool;
2476    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2477    fn rust_crate_root_file(&self, caller_file: &str) -> Option<String>;
2478    fn inline_scoped_target(
2479        &self,
2480        caller_file: &str,
2481        module_segments: &[String],
2482        short_name: &str,
2483    ) -> Option<(String, String)>;
2484}
2485
2486impl ResolverIndex for ProjectIndex<'_> {
2487    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2488        self.caller_data.get(file).copied()
2489    }
2490
2491    fn lang_for(&self, file: &str) -> Option<LangId> {
2492        self.lang_for(file)
2493    }
2494
2495    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2496        self.module_target(caller_file, module_path)
2497    }
2498
2499    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2500        let mut parents = self
2501            .files
2502            .iter()
2503            .flat_map(|(file, index)| {
2504                index
2505                    .declared_module_targets
2506                    .iter()
2507                    .filter_map(move |(module, target)| {
2508                        (target.as_deref() == Some(target_file))
2509                            .then(|| (file.clone(), module.clone()))
2510                    })
2511            })
2512            .collect::<Vec<_>>();
2513        parents.sort();
2514        parents.into_iter().next()
2515    }
2516
2517    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2518        self.reexports_for(file).to_vec()
2519    }
2520
2521    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2522        self.node_for_symbol(file, symbol)
2523    }
2524
2525    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2526        self.node_is_callable(file, node_id)
2527    }
2528
2529    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2530        self.files
2531            .get(file)
2532            .and_then(|item| item.export_aliases.get(symbol))
2533            .cloned()
2534    }
2535
2536    fn has_export(&self, file: &str, symbol: &str) -> bool {
2537        self.files
2538            .get(file)
2539            .is_some_and(|item| item.exports.contains(symbol))
2540    }
2541
2542    fn default_export(&self, file: &str) -> Option<String> {
2543        self.files
2544            .get(file)
2545            .and_then(|item| item.default_export.clone())
2546    }
2547
2548    fn contains_file(&self, file: &str) -> bool {
2549        self.files.contains_key(file)
2550    }
2551
2552    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2553        if self.workspace_crate_prefixes.0.get().is_some() {
2554            self.facts.memo_replay(&self.project_root, "crates", "");
2555        }
2556        self.workspace_crate_prefixes
2557            .0
2558            .get_or_init(|| {
2559                self.facts.memo_start(&self.project_root, "crates", "");
2560                let prefixes = build_workspace_crate_prefixes(
2561                    &self.project_root,
2562                    &FactPaths {
2563                        root: &self.project_root,
2564                        facts: self.facts.as_ref(),
2565                    },
2566                );
2567                self.facts.memo_finish(&self.project_root, "crates", "");
2568                prefixes
2569            })
2570            .get(crate_name)
2571            .cloned()
2572    }
2573
2574    fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2575        let paths = FactPaths {
2576            root: &self.project_root,
2577            facts: self.facts.as_ref(),
2578        };
2579        callgraph::rust_crate_root_file_for_caller(
2580            &self.project_root,
2581            &self.project_root.join(caller_file),
2582            &paths,
2583            &self.rust_crate_roots,
2584        )
2585        .map(|path| relative_path(&self.project_root, &path))
2586    }
2587
2588    fn inline_scoped_target(
2589        &self,
2590        caller_file: &str,
2591        module_segments: &[String],
2592        short_name: &str,
2593    ) -> Option<(String, String)> {
2594        let src_prefix = rust_src_prefix(caller_file);
2595        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2596        file_paths.sort();
2597        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2598            let caller = file_paths.remove(position);
2599            file_paths.insert(0, caller);
2600        }
2601        for file_path in file_paths {
2602            if self.lang_for(&file_path) != Some(LangId::Rust)
2603                || rust_src_prefix(&file_path) != src_prefix
2604            {
2605                continue;
2606            }
2607            let file_module_segments = rust_module_segments_for_rel(&file_path);
2608            if !module_segments.starts_with(&file_module_segments) {
2609                continue;
2610            }
2611            let scoped_segments = &module_segments[file_module_segments.len()..];
2612            if scoped_segments.is_empty() {
2613                continue;
2614            }
2615            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2616            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2617                return Some((file_path, scoped_symbol));
2618            }
2619        }
2620        None
2621    }
2622}
2623
2624/// A cold-build resolver view that loads file indexes on demand. The bounded
2625/// memo keeps repeated lookups cheap without making the heap proportional to an
2626/// unbounded project corpus.
2627///
2628/// Instances are created only after extraction commits every `files`, `nodes`,
2629/// `file_dependencies`, and structural `refs` input and the indexing fence moves
2630/// staging to `resolving`. Resolution replaces `refs` rows only to add status,
2631/// target, and provenance outputs, and inserts `edges`; `DbFileIndex` reads none
2632/// of those output columns. Its inputs therefore stay immutable for an instance,
2633/// so the memo needs no generation key or slice-fence invalidation.
2634struct DiskProjectIndex<'a> {
2635    project_root: &'a Path,
2636    conn: &'a Connection,
2637    caller_file: &'a str,
2638    caller_data: &'a FileCallData,
2639    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2640    module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2641    file_index_memo: RefCell<HashMap<String, Option<Rc<DbFileIndex>>>>,
2642    module_parent_memo: RefCell<HashMap<String, Option<(String, String)>>>,
2643    memoize_resolver_indexes: bool,
2644}
2645
2646impl DiskProjectIndex<'_> {
2647    fn file_index(&self, rel_path: &str) -> Option<Rc<DbFileIndex>> {
2648        if self.memoize_resolver_indexes {
2649            if let Some(cached) = self.file_index_memo.borrow().get(rel_path).cloned() {
2650                return cached;
2651            }
2652        }
2653
2654        let loaded = self.load_file_index(rel_path).map(Rc::new);
2655        if self.memoize_resolver_indexes {
2656            let mut memo = self.file_index_memo.borrow_mut();
2657            if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2658                // Keep the active caller's index hot even when an unusually broad
2659                // resolution walk exhausts the bounded target-file memo.
2660                let caller_index = memo.remove(self.caller_file);
2661                memo.clear();
2662                if let Some(caller_index) = caller_index {
2663                    memo.insert(self.caller_file.to_string(), caller_index);
2664                }
2665            }
2666            memo.insert(rel_path.to_string(), loaded.clone());
2667        }
2668        loaded
2669    }
2670
2671    fn load_file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2672        let lang: String = self
2673            .conn
2674            .query_row(
2675                "SELECT lang FROM files WHERE path = ?1",
2676                params![rel_path],
2677                |row| row.get(0),
2678            )
2679            .optional()
2680            .ok()??;
2681        let mut index = DbFileIndex {
2682            lang: lang_from_label(&lang),
2683            exports: HashSet::new(),
2684            default_export: None,
2685            export_aliases: HashMap::new(),
2686            node_by_scoped: HashMap::new(),
2687            node_by_bare: HashMap::new(),
2688            node_kind_by_id: HashMap::new(),
2689            module_targets: HashMap::new(),
2690            declared_module_targets: HashMap::new(),
2691            reexports: Vec::new(),
2692        };
2693        let mut nodes = self
2694            .conn
2695            .prepare(
2696                "SELECT id, name, scoped_name, kind, exported, is_default_export
2697                 FROM nodes WHERE file_path = ?1",
2698            )
2699            .ok()?;
2700        let rows = nodes
2701            .query_map(params![rel_path], |row| {
2702                Ok((
2703                    row.get::<_, String>(0)?,
2704                    row.get::<_, String>(1)?,
2705                    row.get::<_, String>(2)?,
2706                    row.get::<_, String>(3)?,
2707                    row.get::<_, i64>(4)? != 0,
2708                    row.get::<_, i64>(5)? != 0,
2709                ))
2710            })
2711            .ok()?
2712            .collect::<std::result::Result<Vec<_>, _>>()
2713            .ok()?;
2714        drop(nodes);
2715        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2716            if exported {
2717                index.exports.insert(name.clone());
2718                index.exports.insert(scoped_name.clone());
2719            }
2720            if is_default_export {
2721                index.default_export = Some(scoped_name.clone());
2722            }
2723            index.node_by_scoped.insert(scoped_name, id.clone());
2724            index.node_by_bare.entry(name).or_insert(id.clone());
2725            index.node_kind_by_id.insert(id, kind);
2726        }
2727
2728        let mut refs = self
2729            .conn
2730            .prepare(
2731                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2732                  FROM refs
2733                  WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2734            )
2735            .ok()?;
2736        let rows = refs
2737            .query_map(params![rel_path], |row| {
2738                Ok((
2739                    row.get::<_, String>(0)?,
2740                    row.get::<_, String>(1)?,
2741                    row.get::<_, Option<String>>(2)?,
2742                    row.get::<_, Option<String>>(3)?,
2743                    row.get::<_, i64>(4)? != 0,
2744                    row.get::<_, Option<String>>(5)?,
2745                    row.get::<_, Option<String>>(6)?,
2746                ))
2747            })
2748            .ok()?
2749            .collect::<std::result::Result<Vec<_>, _>>()
2750            .ok()?;
2751        drop(refs);
2752        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2753            if kind == "export_alias" {
2754                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2755                    index.export_aliases.insert(exported, source);
2756                }
2757                continue;
2758            }
2759            let Some(module_path) = module_path else {
2760                continue;
2761            };
2762            let target_file = if kind == "module" {
2763                rust_declared_module_target(
2764                    self.project_root,
2765                    rel_path,
2766                    &module_path,
2767                    self.module_resolution_memo,
2768                    &FactPaths {
2769                        root: self.project_root,
2770                        facts: &DiskFacts::new(self.project_root),
2771                    },
2772                )
2773            } else {
2774                self.disk_module_target(rel_path, &module_path)
2775            }
2776            .or_else(|| {
2777                self.conn
2778                    .query_row(
2779                        "SELECT d.dep_file
2780                         FROM file_dependencies d
2781                         JOIN files f ON f.path = d.dep_file
2782                         WHERE d.file_path = ?1
2783                         ORDER BY d.dep_file
2784                         LIMIT 1",
2785                        params![rel_path],
2786                        |row| row.get::<_, String>(0),
2787                    )
2788                    .optional()
2789                    .ok()
2790                    .flatten()
2791            });
2792            index
2793                .module_targets
2794                .entry(module_path.clone())
2795                .or_insert_with(|| target_file.clone());
2796            if kind == "module" {
2797                index
2798                    .declared_module_targets
2799                    .entry(module_path.clone())
2800                    .or_insert_with(|| target_file.clone());
2801            }
2802            if kind == "reexport" {
2803                let raw = RawRef {
2804                    ref_id,
2805                    caller_node: None,
2806                    caller_symbol: None,
2807                    caller_file: rel_path.to_string(),
2808                    kind,
2809                    short_name: None,
2810                    full_ref,
2811                    module_path: Some(module_path),
2812                    import_kind: Some("reexport".to_string()),
2813                    local_name: None,
2814                    requested_name: None,
2815                    namespace_alias: None,
2816                    wildcard,
2817                    line: 0,
2818                    byte_start: 0,
2819                    byte_end: 0,
2820                    dependencies: BTreeSet::new(),
2821                };
2822                index
2823                    .reexports
2824                    .push(reexport_index_from_raw(&raw, target_file));
2825            }
2826        }
2827        Some(index)
2828    }
2829
2830    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2831        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2832        let candidate = callgraph::resolve_module_path_with_memo(
2833            &caller_dir,
2834            module_path,
2835            self.module_resolution_memo,
2836            &FactPaths {
2837                root: self.project_root,
2838                facts: &DiskFacts::new(self.project_root),
2839            },
2840        )?;
2841        let rel_path = relative_path(self.project_root, &candidate);
2842        self.contains_file(&rel_path).then_some(rel_path)
2843    }
2844
2845    fn load_module_parent(&self, target_file: &str) -> Option<(String, String)> {
2846        let mut stmt = self
2847            .conn
2848            .prepare(
2849                "SELECT caller_file, module_path FROM refs
2850                 WHERE kind = 'module' AND module_path IS NOT NULL
2851                 ORDER BY caller_file, module_path",
2852            )
2853            .ok()?;
2854        let rows = stmt
2855            .query_map([], |row| {
2856                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2857            })
2858            .ok()?;
2859        for row in rows.flatten() {
2860            if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2861                return Some(row);
2862            }
2863        }
2864        None
2865    }
2866}
2867
2868impl ResolverIndex for DiskProjectIndex<'_> {
2869    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2870        (file == self.caller_file).then_some(self.caller_data)
2871    }
2872
2873    fn lang_for(&self, file: &str) -> Option<LangId> {
2874        self.file_index(file).and_then(|index| index.lang)
2875    }
2876
2877    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2878        self.file_index(caller_file)
2879            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2880    }
2881
2882    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2883        if self.memoize_resolver_indexes {
2884            if let Some(cached) = self.module_parent_memo.borrow().get(target_file).cloned() {
2885                return cached;
2886            }
2887        }
2888
2889        let parent = self.load_module_parent(target_file);
2890        if self.memoize_resolver_indexes {
2891            let mut memo = self.module_parent_memo.borrow_mut();
2892            if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2893                memo.clear();
2894            }
2895            memo.insert(target_file.to_string(), parent.clone());
2896        }
2897        parent
2898    }
2899
2900    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2901        self.file_index(file)
2902            .map(|index| index.reexports.clone())
2903            .unwrap_or_default()
2904    }
2905
2906    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2907        self.file_index(file).and_then(|index| {
2908            index
2909                .node_by_scoped
2910                .get(symbol)
2911                .cloned()
2912                .or_else(|| index.node_by_bare.get(symbol).cloned())
2913        })
2914    }
2915
2916    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2917        self.file_index(file)
2918            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2919            .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
2920    }
2921
2922    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2923        self.file_index(file)
2924            .and_then(|index| index.export_aliases.get(symbol).cloned())
2925    }
2926
2927    fn has_export(&self, file: &str, symbol: &str) -> bool {
2928        self.file_index(file)
2929            .is_some_and(|index| index.exports.contains(symbol))
2930    }
2931
2932    fn default_export(&self, file: &str) -> Option<String> {
2933        self.file_index(file)
2934            .and_then(|index| index.default_export.clone())
2935    }
2936
2937    fn contains_file(&self, file: &str) -> bool {
2938        self.conn
2939            .query_row(
2940                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2941                params![file],
2942                |_| Ok(()),
2943            )
2944            .is_ok()
2945    }
2946
2947    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2948        self.workspace_crate_prefixes
2949            .0
2950            .get_or_init(|| {
2951                build_workspace_crate_prefixes(
2952                    self.project_root,
2953                    &FactPaths {
2954                        root: self.project_root,
2955                        facts: &DiskFacts::new(self.project_root),
2956                    },
2957                )
2958            })
2959            .get(crate_name)
2960            .cloned()
2961    }
2962
2963    fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2964        let disk = DiskFacts::new(self.project_root);
2965        let paths = FactPaths {
2966            root: self.project_root,
2967            facts: &disk,
2968        };
2969        self.module_resolution_memo
2970            .rust_crate_root_file(
2971                self.project_root,
2972                &self.project_root.join(caller_file),
2973                &paths,
2974            )
2975            .map(|path| relative_path(self.project_root, &path))
2976    }
2977
2978    fn inline_scoped_target(
2979        &self,
2980        caller_file: &str,
2981        module_segments: &[String],
2982        short_name: &str,
2983    ) -> Option<(String, String)> {
2984        let src_prefix = rust_src_prefix(caller_file);
2985        let check = |file_path: String| {
2986            let file_module_segments = rust_module_segments_for_rel(&file_path);
2987            if rust_src_prefix(&file_path) != src_prefix
2988                || !module_segments.starts_with(&file_module_segments)
2989            {
2990                return None;
2991            }
2992            let scoped_segments = &module_segments[file_module_segments.len()..];
2993            if scoped_segments.is_empty() {
2994                return None;
2995            }
2996            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2997            self.node_for_symbol(&file_path, &scoped_symbol)
2998                .map(|_| (file_path, scoped_symbol))
2999        };
3000        if let Some(target) = check(caller_file.to_string()) {
3001            return Some(target);
3002        }
3003        let mut statement = self
3004            .conn
3005            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
3006            .ok()?;
3007        let rows = statement
3008            .query_map(params![caller_file], |row| row.get::<_, String>(0))
3009            .ok()?;
3010        for path in rows.flatten() {
3011            if let Some(target) = check(path) {
3012                return Some(target);
3013            }
3014        }
3015        None
3016    }
3017}
3018
3019impl CallGraphStore {
3020    pub fn open_if_enabled(
3021        options: CallGraphStoreOptions,
3022        callgraph_dir: PathBuf,
3023        project_root: PathBuf,
3024    ) -> Result<Option<Self>> {
3025        if !options.enabled {
3026            return Ok(None);
3027        }
3028        Self::open(callgraph_dir, project_root).map(Some)
3029    }
3030
3031    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
3032        let project_key = crate::search_index::artifact_cache_key(&project_root);
3033        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3034        else {
3035            return Err(CallGraphStoreError::Unavailable(
3036                "writer capability denied; use the read-only callgraph opener".to_string(),
3037            ));
3038        };
3039        std::fs::create_dir_all(&callgraph_dir)?;
3040        // Resolve the current generation via the pointer (falling back to the
3041        // legacy single-file DB). If nothing is published yet, open the legacy
3042        // path so a brand-new store still gets a writable DB + schema.
3043        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
3044            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
3045        let OpenedStore { store, root_repair } = Self::open_at_path(
3046            project_root.clone(),
3047            project_key,
3048            sqlite_path,
3049            generation,
3050            true,
3051            Some(Arc::clone(&writer_lease)),
3052            None,
3053        )?;
3054        match root_repair {
3055            OpenRootRepair::NeedsRebuild { .. } => {
3056                log_root_repair_rebuild(&root_repair);
3057                drop(store);
3058                drop(writer_lease);
3059                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3060                let (store, _stats) =
3061                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3062                Ok(store)
3063            }
3064            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
3065        }
3066    }
3067
3068    pub fn open_readonly(
3069        callgraph_dir: PathBuf,
3070        project_root: PathBuf,
3071    ) -> Result<Option<ReadonlyCallGraphStore>> {
3072        let project_key = crate::search_index::artifact_cache_key(&project_root);
3073        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3074        {
3075            let conn = open_readonly_connection(&sqlite_path)?;
3076            if !database_ready(&conn).unwrap_or(false) {
3077                return Ok(None);
3078            }
3079            let marker_label = generation.as_deref().unwrap_or("legacy");
3080            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
3081            return Ok(Some(ReadonlyCallGraphStore::from_inner(
3082                Self::from_connection(
3083                    project_root,
3084                    project_key,
3085                    sqlite_path,
3086                    callgraph_dir,
3087                    false,
3088                    generation,
3089                    None,
3090                    Some(read_marker),
3091                    conn,
3092                ),
3093            )));
3094        }
3095
3096        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
3097            return Ok(None);
3098        };
3099        crate::slog_warn!(
3100            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
3101            target.partition.harness,
3102            target.sqlite_path.display()
3103        );
3104        let conn = open_readonly_connection(&target.sqlite_path)?;
3105        if !database_ready(&conn).unwrap_or(false) {
3106            return Ok(None);
3107        }
3108        let marker_label =
3109            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
3110        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
3111        Ok(Some(ReadonlyCallGraphStore::from_inner(
3112            Self::from_connection(
3113                project_root,
3114                project_key,
3115                target.sqlite_path,
3116                callgraph_dir,
3117                true,
3118                target.generation,
3119                None,
3120                Some(read_marker),
3121                conn,
3122            ),
3123        )))
3124    }
3125
3126    /// Open the currently-published ready store with write access so moved-root
3127    /// metadata can be repaired before projection readers consume it. Unlike
3128    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
3129    /// ready generation exists, it returns `Ok(None)` instead of creating an
3130    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
3131    pub fn open_ready_repairing(
3132        callgraph_dir: PathBuf,
3133        project_root: PathBuf,
3134    ) -> Result<Option<Self>> {
3135        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
3136    }
3137
3138    /// Open a ready store for bounded maintenance work without repairing root
3139    /// metadata or starting a cold rebuild. A store that needs either action is
3140    /// reported as unavailable so a background build can own that work.
3141    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
3142        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
3143    }
3144
3145    pub fn open_ready_no_rebuild(
3146        callgraph_dir: PathBuf,
3147        project_root: PathBuf,
3148    ) -> Result<Option<Self>> {
3149        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
3150    }
3151
3152    fn open_ready_with_rebuild_policy(
3153        callgraph_dir: PathBuf,
3154        project_root: PathBuf,
3155        allow_cold_build: bool,
3156        allow_root_repair: bool,
3157    ) -> Result<Option<Self>> {
3158        let project_key = crate::search_index::artifact_cache_key(&project_root);
3159        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3160        else {
3161            return Ok(None);
3162        };
3163        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3164        else {
3165            return Ok(None);
3166        };
3167        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
3168            project_root.clone(),
3169            project_key.clone(),
3170            sqlite_path,
3171            generation,
3172            true,
3173            Some(Arc::clone(&writer_lease)),
3174            None,
3175            allow_root_repair,
3176        )?;
3177        match root_repair {
3178            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
3179                log_root_repair_rebuild(&root_repair);
3180                drop(store);
3181                drop(writer_lease);
3182                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3183                let (store, _stats) =
3184                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3185                Ok(Some(store))
3186            }
3187            OpenRootRepair::NeedsRebuild { .. } => {
3188                if let Some(message) = note_repair_entry(&project_key) {
3189                    crate::slog_warn!("{message}");
3190                }
3191                Ok(None)
3192            }
3193            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3194        }
3195    }
3196
3197    pub fn cold_build_with_lease(
3198        callgraph_dir: PathBuf,
3199        project_root: PathBuf,
3200        files: &[PathBuf],
3201    ) -> Result<(Self, ColdBuildStats)> {
3202        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
3203    }
3204
3205    pub fn cold_build_with_lease_chunked(
3206        callgraph_dir: PathBuf,
3207        project_root: PathBuf,
3208        files: &[PathBuf],
3209        chunk_size: usize,
3210    ) -> Result<(Self, ColdBuildStats)> {
3211        Self::cold_build_with_lease_chunked_inner(
3212            callgraph_dir,
3213            project_root,
3214            files,
3215            chunk_size,
3216            false,
3217        )
3218    }
3219
3220    pub(crate) fn force_cold_build_with_lease_chunked(
3221        callgraph_dir: PathBuf,
3222        project_root: PathBuf,
3223        files: &[PathBuf],
3224        chunk_size: usize,
3225    ) -> Result<(Self, ColdBuildStats)> {
3226        Self::cold_build_with_lease_chunked_inner(
3227            callgraph_dir,
3228            project_root,
3229            files,
3230            chunk_size,
3231            true,
3232        )
3233    }
3234
3235    fn cold_build_with_lease_chunked_inner(
3236        callgraph_dir: PathBuf,
3237        project_root: PathBuf,
3238        files: &[PathBuf],
3239        chunk_size: usize,
3240        require_new_publication: bool,
3241    ) -> Result<(Self, ColdBuildStats)> {
3242        let project_key = crate::search_index::artifact_cache_key(&project_root);
3243        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3244        else {
3245            let operation = if require_new_publication {
3246                "forced rebuild"
3247            } else {
3248                "cold build"
3249            };
3250            return Err(CallGraphStoreError::Unavailable(format!(
3251                "{operation} could not acquire writer capability"
3252            )));
3253        };
3254        std::fs::create_dir_all(&callgraph_dir)?;
3255        let (stats, generation) = Self::cold_build_publish_locked(
3256            &callgraph_dir,
3257            &project_root,
3258            &project_key,
3259            files,
3260            chunk_size,
3261            Arc::clone(&writer_lease),
3262        )?;
3263        let store = Self::open_generation(
3264            &callgraph_dir,
3265            project_root,
3266            project_key,
3267            generation,
3268            writer_lease,
3269        )?;
3270        Ok((store, stats))
3271    }
3272
3273    pub fn ensure_built_with_lease(
3274        callgraph_dir: PathBuf,
3275        project_root: PathBuf,
3276        files: &[PathBuf],
3277    ) -> Result<(Self, Option<ColdBuildStats>)> {
3278        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
3279    }
3280
3281    pub fn ensure_built_with_lease_chunked(
3282        callgraph_dir: PathBuf,
3283        project_root: PathBuf,
3284        files: &[PathBuf],
3285        chunk_size: usize,
3286    ) -> Result<(Self, Option<ColdBuildStats>)> {
3287        let project_key = crate::search_index::artifact_cache_key(&project_root);
3288        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3289        else {
3290            return Err(CallGraphStoreError::Unavailable(
3291                "callgraph ensure could not acquire writer capability".to_string(),
3292            ));
3293        };
3294        std::fs::create_dir_all(&callgraph_dir)?;
3295        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3296        // Another process may have published a ready generation while we waited
3297        // for the lock — open it instead of rebuilding. If that generation is
3298        // from this same project at an older filesystem root, repair the root
3299        // metadata in-place while still holding the build lease. If data rows
3300        // contain absolute paths, publish a fresh generation under this lease
3301        // rather than recursively reacquiring the same lock.
3302        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3303        {
3304            let OpenedStore { store, root_repair } = Self::open_at_path(
3305                project_root.clone(),
3306                project_key.clone(),
3307                sqlite_path,
3308                generation,
3309                true,
3310                Some(Arc::clone(&writer_lease)),
3311                None,
3312            )?;
3313            match root_repair {
3314                OpenRootRepair::NeedsRebuild { .. } => {
3315                    log_root_repair_rebuild(&root_repair);
3316                    drop(store);
3317                    let (stats, generation) = Self::cold_build_publish_locked(
3318                        &callgraph_dir,
3319                        &project_root,
3320                        &project_key,
3321                        files,
3322                        chunk_size,
3323                        Arc::clone(&writer_lease),
3324                    )?;
3325                    let store = Self::open_generation(
3326                        &callgraph_dir,
3327                        project_root,
3328                        project_key,
3329                        generation,
3330                        writer_lease,
3331                    )?;
3332                    return Ok((store, Some(stats)));
3333                }
3334                OpenRootRepair::None | OpenRootRepair::ReRooted => {
3335                    return Ok((store, None));
3336                }
3337            }
3338        }
3339        if let Some(store) = try_legacy_migration_or_fallback(
3340            &callgraph_dir,
3341            &project_root,
3342            &project_key,
3343            Arc::clone(&writer_lease),
3344        )? {
3345            return Ok((store, None));
3346        }
3347        let (stats, generation) = Self::cold_build_publish_locked(
3348            &callgraph_dir,
3349            &project_root,
3350            &project_key,
3351            files,
3352            chunk_size,
3353            Arc::clone(&writer_lease),
3354        )?;
3355        let store = Self::open_generation(
3356            &callgraph_dir,
3357            project_root,
3358            project_key,
3359            generation,
3360            writer_lease,
3361        )?;
3362        Ok((store, Some(stats)))
3363    }
3364
3365    /// Migrate a legacy harness-partition store without falling through to a
3366    /// cold build. This is used after a query has already opened a read-only
3367    /// fallback: the caller runs it on the same limited background lane as cold
3368    /// builds while queries continue using that fallback. Public so crash/retry
3369    /// tests can drive the migration synchronously on a thread where the
3370    /// thread-local failure seams apply.
3371    pub fn migrate_legacy_with_lease(
3372        callgraph_dir: PathBuf,
3373        project_root: PathBuf,
3374    ) -> Result<Option<Self>> {
3375        let project_key = crate::search_index::artifact_cache_key(&project_root);
3376        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3377        else {
3378            return Ok(None);
3379        };
3380        std::fs::create_dir_all(&callgraph_dir)?;
3381        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3382
3383        // Another writer may have completed the migration while this worker was
3384        // waiting for the lease. Adopt its root-keyed generation rather than
3385        // copying the legacy source a second time.
3386        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3387        {
3388            let OpenedStore { store, root_repair } = Self::open_at_path(
3389                project_root,
3390                project_key,
3391                sqlite_path,
3392                generation,
3393                true,
3394                Some(writer_lease),
3395                None,
3396            )?;
3397            return match root_repair {
3398                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3399                OpenRootRepair::NeedsRebuild { reason, .. } => {
3400                    Err(CallGraphStoreError::Unavailable(format!(
3401                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3402                    )))
3403                }
3404            };
3405        }
3406
3407        let store = try_legacy_migration_or_fallback(
3408            &callgraph_dir,
3409            &project_root,
3410            &project_key,
3411            writer_lease,
3412        )?;
3413        // A disk-floor or backup-budget failure returns a readable legacy store.
3414        // Keep the already-resident fallback instead of sending this duplicate
3415        // reader through the background-install channel.
3416        Ok(store.filter(|store| !store.is_legacy_fallback()))
3417    }
3418
3419    /// Build a fresh DB and publish it as a new generation, then atomically flip
3420    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
3421    /// succeeds even when other processes hold an older generation open (the
3422    /// multi-TUI Windows case). The builder owns the temp + generation files
3423    /// exclusively (unique pid+nanos names), so it can rename/replace them
3424    /// freely; only the tiny pointer is shared, and only Rust std touches it.
3425    ///
3426    /// Returns the published generation file name so callers open exactly the
3427    /// generation they built (avoiding a race where a concurrent build's flip
3428    /// would otherwise reopen a different generation).
3429    fn cold_build_publish_locked(
3430        callgraph_dir: &Path,
3431        project_root: &Path,
3432        project_key: &str,
3433        files: &[PathBuf],
3434        chunk_size: usize,
3435        writer_lease: Arc<crate::root_cache::WriterLease>,
3436    ) -> Result<(ColdBuildStats, String)> {
3437        if let Some((previous_root, remaining)) =
3438            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3439        {
3440            return Err(CallGraphStoreError::Unavailable(format!(
3441                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3442                previous_root.display(),
3443                remaining.as_millis()
3444            )));
3445        }
3446        let breaker = crate::build_breaker::BuildDeathBreaker::open(
3447            callgraph_dir.join("build-breaker.sqlite"),
3448        )
3449        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3450
3451        let generation = generation_file_name(project_key);
3452        let gen_path = callgraph_dir.join(&generation);
3453        // A writer lease makes this root/domain's staging generation exclusive.
3454        // Keep its identity stable so a replacement process adopts committed
3455        // batches instead of minting a second temp and starting from zero.
3456        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3457        let adopting_staging = temp_path.exists();
3458        if !adopting_staging {
3459            remove_sqlite_file_set(&temp_path);
3460        }
3461
3462        let scope = crate::logging::IndexBuildScope::new(
3463            crate::logging::IndexPlane::Callgraph,
3464            project_root,
3465            project_key,
3466        );
3467        let _index_build = crate::logging::install_index_build(scope.clone());
3468        let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
3469        let mut started = crate::logging::IndexEvent::from_scope(
3470            crate::logging::IndexEventKind::BuildStarted,
3471            &scope,
3472        );
3473        if adopting_staging {
3474            started = started.field("resumed_from_staging", "true");
3475        }
3476        crate::logging::log_index_event(started);
3477
3478        let (stats, breaker_key) = {
3479            if adopting_staging {
3480                crate::slog_info!(
3481                    "resuming callgraph cold build from staged generation {}",
3482                    temp_path.display()
3483                );
3484            }
3485            let temp_store = Self::open_at_path(
3486                project_root.to_path_buf(),
3487                project_key.to_string(),
3488                temp_path.clone(),
3489                None,
3490                false,
3491                Some(Arc::clone(&writer_lease)),
3492                None,
3493            )?
3494            .store;
3495            // Admission must precede every expensive build phase and every
3496            // staging write: a suspended root is refused before the process
3497            // spends anything, and a death during enumeration is attributable
3498            // to an admitted attempt. The breaker key needs the corpus
3499            // fingerprint, so that one input is resolved by a standalone
3500            // streaming walk first (sanctioned pre-admission work) - the
3501            // inventory pass below recomputes it while staging; the staged
3502            // value governs resume cursors, while the admission key stays
3503            // pinned to the admitted fingerprint so a file racing the walk
3504            // cannot detach the attempt from its breaker record.
3505            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3506            let breaker_key = crate::build_breaker::BreakerKey::new(
3507                project_root.display().to_string(),
3508                crate::build_breaker::BuildDomain::CallgraphCold,
3509                admission_fingerprint,
3510            );
3511            match breaker
3512                .admit(&breaker_key, 0)
3513                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3514            {
3515                crate::build_breaker::BreakerAdmission::Admitted(_) => {
3516                    crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3517                        crate::logging::IndexEventKind::BreakerAdmitted,
3518                        &scope,
3519                    ));
3520                }
3521                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3522                    crate::logging::log_index_event(
3523                        crate::logging::IndexEvent::from_scope(
3524                            crate::logging::IndexEventKind::BuildSuspended,
3525                            &scope,
3526                        )
3527                        .field("reason", &suspension.reason),
3528                    );
3529                    failure_guard.disarm();
3530                    return Err(CallGraphStoreError::Suspended(suspension));
3531                }
3532            }
3533            ensure_cold_build_current("inventory", 0, 1)?;
3534            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3535            ensure_cold_build_current("inventory", 1, 1)?;
3536            let stats = temp_store
3537                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3538            let _ = temp_store.checkpoint_wal_truncate();
3539            temp_store.prepare_for_atomic_swap()?;
3540            (stats, breaker_key)
3541        };
3542
3543        notify_cold_build_before_publish_observer();
3544        let publication = publish_if_current(|| {
3545            verify_writer_lease(&writer_lease)?;
3546            // Move the finished build to its final generation path. This target is
3547            // brand-new and owned by us, so the rename never hits an open file.
3548            remove_sqlite_file_set(&gen_path);
3549            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3550            crate::fs_lock::sync_parent(&gen_path);
3551            remove_sqlite_sidecars(&gen_path);
3552
3553            notify_cold_build_swap_observer(&temp_path, &gen_path);
3554
3555            // Atomically publish the new generation, then best-effort GC old ones.
3556            verify_writer_lease(&writer_lease)?;
3557            publish_pointer(callgraph_dir, project_key, &generation)?;
3558            gc_old_generations(callgraph_dir, project_key, &generation);
3559            // Store-wide orphan sweep on the same cadence: reclaims aged build
3560            // temps for roots that no longer build here, which the per-root GC
3561            // above never reaches.
3562            sweep_orphaned_build_temps_store_wide(callgraph_dir);
3563            sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3564            crate::search_index::sweep_transient_search_cache_dirs();
3565            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3566                let inspect_root =
3567                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3568                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3569                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3570            }
3571            Ok(())
3572        });
3573        // A superseded generation remains a valid resumable staging artifact.
3574        // Its successor compares the durable corpus fingerprint before either
3575        // adopting this work or resetting it for a changed corpus.
3576        if let Err(CallGraphStoreError::Superseded) = &publication {
3577            crate::logging::log_index_event(
3578                crate::logging::IndexEvent::from_scope(
3579                    crate::logging::IndexEventKind::BuildSuperseded,
3580                    &scope,
3581                )
3582                .field("stage", "publish"),
3583            );
3584            failure_guard.disarm();
3585        }
3586        publication?;
3587        // Pointer publication is the only automatic breaker reset. The staging
3588        // batches above never reset history because a process can die after them.
3589        breaker
3590            .record_ready_publication(&breaker_key)
3591            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3592        crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3593            crate::logging::IndexEventKind::BreakerReset,
3594            &scope,
3595        ));
3596        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3597        crate::logging::log_index_event(
3598            crate::logging::IndexEvent::from_scope(
3599                crate::logging::IndexEventKind::BuildReady,
3600                &scope,
3601            )
3602            .field("elapsed_ms", scope.elapsed_ms())
3603            .field("files", stats.files)
3604            .field("edges", stats.edges),
3605        );
3606        failure_guard.disarm();
3607        Ok((stats, generation))
3608    }
3609
3610    /// Open a specific just-published generation (read-write, WAL) so a builder
3611    /// returns a store pinned to exactly what it built.
3612    fn open_generation(
3613        callgraph_dir: &Path,
3614        project_root: PathBuf,
3615        project_key: String,
3616        generation: String,
3617        writer_lease: Arc<crate::root_cache::WriterLease>,
3618    ) -> Result<Self> {
3619        let gen_path = callgraph_dir.join(&generation);
3620        Ok(Self::open_at_path(
3621            project_root,
3622            project_key,
3623            gen_path,
3624            Some(generation),
3625            true,
3626            Some(writer_lease),
3627            None,
3628        )?
3629        .store)
3630    }
3631
3632    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3633        let project_key = crate::search_index::artifact_cache_key(project_root);
3634        // A cold build is needed unless a ready generation (or ready legacy DB)
3635        // is currently published.
3636        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3637    }
3638
3639    /// Check the durable callgraph-domain breaker before a query starts a cold
3640    /// worker. This only runs while no ready generation exists; it never builds
3641    /// inline and lets a tripped root return a terminal answer instead of an
3642    /// endless `Building` response.
3643    pub fn cold_build_suspension(
3644        callgraph_dir: &Path,
3645        project_root: &Path,
3646    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3647        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3648        if !breaker_path.exists() {
3649            return Ok(None);
3650        }
3651        let key = crate::build_breaker::BreakerKey::new(
3652            project_root.display().to_string(),
3653            crate::build_breaker::BuildDomain::CallgraphCold,
3654            callgraph_corpus_fingerprint(project_root)?,
3655        );
3656        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3657            .and_then(|breaker| breaker.suspension(&key))
3658            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3659    }
3660
3661    fn open_at_path(
3662        project_root: PathBuf,
3663        project_key: String,
3664        sqlite_path: PathBuf,
3665        generation: Option<String>,
3666        use_wal: bool,
3667        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3668        read_marker: Option<crate::root_cache::ReadMarker>,
3669    ) -> Result<OpenedStore> {
3670        Self::open_at_path_with_root_repair(
3671            project_root,
3672            project_key,
3673            sqlite_path,
3674            generation,
3675            use_wal,
3676            writer_lease,
3677            read_marker,
3678            true,
3679        )
3680    }
3681
3682    fn open_at_path_with_root_repair(
3683        project_root: PathBuf,
3684        project_key: String,
3685        sqlite_path: PathBuf,
3686        generation: Option<String>,
3687        use_wal: bool,
3688        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3689        read_marker: Option<crate::root_cache::ReadMarker>,
3690        allow_root_repair: bool,
3691    ) -> Result<OpenedStore> {
3692        if let Some(lease) = writer_lease.as_ref() {
3693            verify_writer_lease(lease)?;
3694        }
3695        if let Some(parent) = sqlite_path.parent() {
3696            std::fs::create_dir_all(parent)?;
3697        }
3698        let mut conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration)?;
3699        if use_wal {
3700            configure_connection(&conn)?;
3701        } else {
3702            configure_build_connection(&conn)?;
3703        }
3704        if let Some(lease) = writer_lease.as_ref() {
3705            verify_writer_lease(lease)?;
3706        }
3707        initialize_schema(&conn)?;
3708        if let Some(lease) = writer_lease.as_ref() {
3709            verify_writer_lease(lease)?;
3710        }
3711        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3712        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3713            (Some(marker), _, _) => Some(marker),
3714            (None, Some(label), Some(cache_dir)) => {
3715                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3716            }
3717            (None, _, _) => None,
3718        };
3719        let publication_dir = sqlite_path
3720            .parent()
3721            .map(Path::to_path_buf)
3722            .unwrap_or_default();
3723        let store = Self::from_connection(
3724            project_root,
3725            project_key,
3726            sqlite_path,
3727            publication_dir,
3728            false,
3729            generation,
3730            writer_lease,
3731            read_marker,
3732            conn,
3733        );
3734        Ok(OpenedStore { store, root_repair })
3735    }
3736
3737    fn prepare_for_atomic_swap(&self) -> Result<()> {
3738        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3739        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3740        Ok(())
3741    }
3742
3743    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3744        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3745            self.sqlite_path
3746                .parent()
3747                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3748        });
3749        if protected_reader {
3750            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3751        } else {
3752            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3753        }
3754    }
3755
3756    fn from_connection(
3757        project_root: PathBuf,
3758        project_key: String,
3759        sqlite_path: PathBuf,
3760        publication_dir: PathBuf,
3761        legacy_fallback: bool,
3762        generation: Option<String>,
3763        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3764        read_marker: Option<crate::root_cache::ReadMarker>,
3765        conn: TrackedConnection,
3766    ) -> Self {
3767        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3768        Self {
3769            project_root,
3770            project_key,
3771            sqlite_path,
3772            publication_dir,
3773            legacy_fallback,
3774            manifest_view: false,
3775            generation,
3776            writer_lease,
3777            read_marker,
3778            database_ready: AtomicBool::new(false),
3779            write_metrics,
3780            conn: Mutex::new(conn),
3781        }
3782    }
3783
3784    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3785        if self.database_ready.load(AtomicOrdering::Acquire) {
3786            return Ok(());
3787        }
3788        ensure_database_ready(conn)?;
3789        self.database_ready.store(true, AtomicOrdering::Release);
3790        Ok(())
3791    }
3792
3793    pub fn project_root(&self) -> &Path {
3794        &self.project_root
3795    }
3796
3797    pub fn project_key(&self) -> &str {
3798        &self.project_key
3799    }
3800
3801    pub fn sqlite_path(&self) -> &Path {
3802        &self.sqlite_path
3803    }
3804
3805    /// The generation file named by the publication pointer when this store opened.
3806    pub(crate) fn projection_generation(&self) -> Option<&str> {
3807        self.generation.as_deref()
3808    }
3809
3810    /// Read the durable revision that changes in the same transaction as graph writes.
3811    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3812        self.refresh_read_marker()?;
3813        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3814        self.ensure_ready(&conn)?;
3815        projection_write_revision(&conn)
3816    }
3817
3818    /// Whether this store is reading from a legacy harness partition because
3819    /// the root-keyed store has not published a generation yet.
3820    pub fn is_legacy_fallback(&self) -> bool {
3821        self.legacy_fallback
3822    }
3823
3824    pub(crate) fn is_legacy_migration(&self) -> bool {
3825        self.generation.as_deref().is_some_and(|generation| {
3826            migration_generation_requires_manifest(generation)
3827                && migration_manifest_valid(&self.publication_dir, generation)
3828        })
3829    }
3830
3831    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3832        self.writer_lease.as_ref().map(|lease| lease.epoch())
3833    }
3834
3835    fn verify_writer_lease(&self) -> Result<()> {
3836        let Some(lease) = self.writer_lease.as_ref() else {
3837            return Err(CallGraphStoreError::Unavailable(
3838                "callgraph store opened read-only; write API is unavailable".to_string(),
3839            ));
3840        };
3841        verify_writer_lease(lease)
3842    }
3843
3844    fn refresh_read_marker(&self) -> Result<()> {
3845        if let Some(marker) = self.read_marker.as_ref() {
3846            marker.touch_if_due()?;
3847        }
3848        Ok(())
3849    }
3850
3851    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3852        self.write_metrics
3853            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3854    }
3855
3856    fn checkpoint_wal_truncate(&self) -> bool {
3857        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3858        checkpoint_wal_truncate(&conn)
3859    }
3860
3861    /// True if this store still reflects the currently-published generation.
3862    /// Cheap (one small pointer-file read). When false, another process (or a
3863    /// local cold rebuild) has published a newer generation and the holder
3864    /// should drop this store and reopen via the pointer to converge. A missing
3865    /// pointer keeps the current store (legacy DB still valid, or transient).
3866    pub fn is_current(&self) -> bool {
3867        let _ = self.refresh_read_marker();
3868        match (
3869            read_pointer(&self.publication_dir, &self.project_key),
3870            &self.generation,
3871        ) {
3872            // Even when both generations happen to have the same filename, the
3873            // root-keyed pointer names a different directory from the fallback.
3874            (Some(_), _) if self.legacy_fallback => false,
3875            (Some(published), Some(opened)) => &published == opened,
3876            // A generation now supersedes the legacy single-file DB we opened.
3877            (Some(_), None) => false,
3878            // No pointer: keep serving (legacy DB, or an anomalous pointer
3879            // removal where our open generation file is still valid).
3880            (None, _) => true,
3881        }
3882    }
3883
3884    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3885        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3886    }
3887
3888    /// Build in two durable passes. Discovery first commits a disk-backed file
3889    /// inventory, extraction consumes bounded batches from that inventory, and
3890    /// resolution pages through staged raw references after all symbols exist.
3891    pub fn cold_build_chunked(
3892        &self,
3893        files: &[PathBuf],
3894        chunk_size: usize,
3895    ) -> Result<ColdBuildStats> {
3896        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3897        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3898    }
3899
3900    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3901        note_cold_build_phase("enumeration");
3902        if files.is_empty() {
3903            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3904                &self.project_root,
3905            ))
3906        } else {
3907            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3908        }
3909    }
3910
3911    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3912    where
3913        I: IntoIterator<Item = PathBuf>,
3914    {
3915        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3916        self.verify_writer_lease()?;
3917        let total_changes_before = conn.total_changes();
3918        let tx = conn.transaction()?;
3919        tx.execute("DELETE FROM staging_file_inventory", [])?;
3920        tx.commit()?;
3921        self.record_commit(total_changes_before, &conn);
3922
3923        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3924        for path in paths {
3925            let path = normalize_file_path(&self.project_root, &path)?;
3926            let rel_path = relative_path(&self.project_root, &path);
3927            let size = std::fs::metadata(&path)
3928                .map(|metadata| metadata.len())
3929                .unwrap_or(0);
3930            batch.push((rel_path, size));
3931            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3932                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3933                batch.clear();
3934            }
3935        }
3936        if !batch.is_empty() {
3937            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3938        }
3939
3940        staged_corpus_fingerprint(&conn, &self.project_root)
3941    }
3942
3943    fn insert_staged_file_inventory_batch(
3944        &self,
3945        conn: &mut Connection,
3946        batch: &[(String, u64)],
3947    ) -> Result<()> {
3948        self.verify_writer_lease()?;
3949        let total_changes_before = conn.total_changes();
3950        let tx = conn.transaction()?;
3951        {
3952            let mut insert = tx.prepare(
3953                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3954            )?;
3955            for (path, size) in batch {
3956                insert.execute(params![path, *size as i64])?;
3957            }
3958        }
3959        tx.commit()?;
3960        self.record_commit(total_changes_before, conn);
3961        Ok(())
3962    }
3963
3964    fn cold_build_chunked_from_staged_inventory(
3965        &self,
3966        chunk_size: usize,
3967        corpus_fingerprint: &str,
3968    ) -> Result<ColdBuildStats> {
3969        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3970        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3971            chunk_size,
3972            corpus_fingerprint,
3973            COLD_BUILD_RESOLVE_WINDOW,
3974            &module_resolution_memo,
3975            true,
3976        )
3977    }
3978
3979    #[cfg(test)]
3980    fn cold_build_chunked_with_resolution_memo_for_test(
3981        &self,
3982        files: &[PathBuf],
3983        chunk_size: usize,
3984        resolve_window: usize,
3985        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3986    ) -> Result<ColdBuildStats> {
3987        self.cold_build_chunked_with_disk_index_memo_for_test(
3988            files,
3989            chunk_size,
3990            resolve_window,
3991            module_resolution_memo,
3992            true,
3993        )
3994    }
3995
3996    #[cfg(test)]
3997    fn cold_build_chunked_with_disk_index_memo_for_test(
3998        &self,
3999        files: &[PathBuf],
4000        chunk_size: usize,
4001        resolve_window: usize,
4002        module_resolution_memo: &callgraph::ModuleResolutionMemo,
4003        memoize_resolver_indexes: bool,
4004    ) -> Result<ColdBuildStats> {
4005        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
4006        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
4007            chunk_size,
4008            &corpus_fingerprint,
4009            resolve_window.max(1),
4010            module_resolution_memo,
4011            memoize_resolver_indexes,
4012        )
4013    }
4014
4015    fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
4016        &self,
4017        chunk_size: usize,
4018        corpus_fingerprint: &str,
4019        resolve_window: usize,
4020        module_resolution_memo: &callgraph::ModuleResolutionMemo,
4021        memoize_resolver_indexes: bool,
4022    ) -> Result<ColdBuildStats> {
4023        let started = Instant::now();
4024        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
4025        let workspace_root = self.project_root.display().to_string();
4026        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4027
4028        self.verify_writer_lease()?;
4029        ensure_cold_build_current("staging-admission", 0, 1)?;
4030        let mut phase = staged_build_phase(&conn)?;
4031        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
4032        let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
4033        if phase.as_deref() == Some("ready") && fingerprint_matches {
4034            ensure_cold_build_current("completed-staging", 1, 1)?;
4035            crate::slog_info!(
4036                "callgraph cold-build decision: reason=matching completed staging; action=publish"
4037            );
4038            conn.execute("DELETE FROM staging_file_inventory", [])?;
4039            return cold_build_stats_from_connection(&conn, started);
4040        }
4041        if phase.is_none() || !fingerprint_matches {
4042            if staged_fingerprint.is_some() && !fingerprint_matches {
4043                crate::slog_info!(
4044                    "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
4045                );
4046            }
4047            let total_changes_before = conn.total_changes();
4048            let tx = conn.transaction()?;
4049            clear_tables(&tx)?;
4050            tx.execute("DELETE FROM staging_ref_context", [])?;
4051            insert_meta(&tx)?;
4052            drop_cold_build_secondary_indexes(&tx)?;
4053            set_meta_ready(&tx, false)?;
4054            set_staged_build_phase(&tx, "extracting")?;
4055            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
4056            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
4057            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
4058            tx.commit()?;
4059            self.record_commit(total_changes_before, &conn);
4060            phase = Some("extracting".to_string());
4061        }
4062
4063        // A crashed extraction pass has already committed complete batches. Compare the
4064        // staged content identity with the current file before parsing so unchanged
4065        // committed files are not restarted from zero after adoption.
4066        note_cold_build_phase("extraction");
4067        if phase.as_deref() == Some("extracting") {
4068            prune_staged_files_not_in_inventory(&mut conn)?;
4069
4070            let total_files =
4071                query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
4072            let mut completed_files = 0usize;
4073            ensure_cold_build_current("extraction", completed_files, total_files)?;
4074            let mut after_path = String::new();
4075            loop {
4076                let Some(batch) = load_staged_file_batch(
4077                    &conn,
4078                    &self.project_root,
4079                    &after_path,
4080                    batch_files,
4081                    COLD_BUILD_EXTRACT_BATCH_BYTES,
4082                )?
4083                else {
4084                    break;
4085                };
4086                after_path = batch.last_path;
4087                let batch_files = batch.paths.len();
4088
4089                let mut needs_extract = Vec::with_capacity(batch_files);
4090                for path in batch.paths {
4091                    if !staged_content_matches(&conn, &self.project_root, &path)? {
4092                        needs_extract.push(path);
4093                    }
4094                }
4095                if needs_extract.is_empty() {
4096                    completed_files = completed_files.saturating_add(batch_files);
4097                    ensure_cold_build_current("extraction", completed_files, total_files)?;
4098                    continue;
4099                }
4100
4101                notify_cold_build_extract_observer(&needs_extract);
4102                let build = build_extracts_parallel(&self.project_root, &needs_extract);
4103                self.verify_writer_lease()?;
4104                let total_changes_before = conn.total_changes();
4105                let tx = conn.transaction()?;
4106                let mut extracted_bytes = 0u64;
4107                {
4108                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4109                    for extract in &build.extracts {
4110                        delete_staged_file_rows(&tx, &extract.rel_path)?;
4111                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4112                        for raw in &extract.raw_refs {
4113                            insert_staged_ref_prepared(&mut inserts, raw)?;
4114                        }
4115                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
4116                    }
4117                    for failure in &build.failures {
4118                        insert_backend_state_prepared(
4119                            &mut inserts.backend_state,
4120                            &workspace_root,
4121                            &failure.rel_path,
4122                            failure
4123                                .freshness
4124                                .as_ref()
4125                                .map(|freshness| &freshness.content_hash),
4126                            "stale",
4127                        )?;
4128                    }
4129                }
4130                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
4131                note_cold_build_commit_barrier("extraction_batch_before_commit");
4132                tx.commit()?;
4133                note_cold_build_commit_barrier("extraction_batch_committed");
4134                self.record_commit(total_changes_before, &conn);
4135                completed_files = completed_files.saturating_add(batch_files);
4136                ensure_cold_build_current("extraction", completed_files, total_files)?;
4137            }
4138
4139            ensure_cold_build_current("extraction", completed_files, total_files)?;
4140            let total_changes_before = conn.total_changes();
4141            let tx = conn.transaction()?;
4142            set_staged_build_phase(&tx, "indexing")?;
4143            tx.commit()?;
4144            self.record_commit(total_changes_before, &conn);
4145            phase = Some("indexing".to_string());
4146            ensure_cold_build_current("extraction", total_files, total_files)?;
4147        }
4148
4149        // Secondary indexes are intentionally created only after every extract is
4150        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
4151        // corpus-wide symbol/export table.
4152        note_cold_build_phase("symbol_export_index");
4153        if phase.as_deref() == Some("indexing") {
4154            ensure_cold_build_current("symbol-export-index", 0, 1)?;
4155            self.verify_writer_lease()?;
4156            let total_changes_before = conn.total_changes();
4157            let tx = conn.transaction()?;
4158            create_cold_build_secondary_indexes(&tx)?;
4159            set_staged_build_phase(&tx, "resolving")?;
4160            tx.commit()?;
4161            self.record_commit(total_changes_before, &conn);
4162            ensure_cold_build_current("symbol-export-index", 1, 1)?;
4163        }
4164
4165        note_cold_build_phase("resolution");
4166        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
4167        let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
4168        let mut resolved_refs =
4169            query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
4170        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4171        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
4172        loop {
4173            let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
4174            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
4175                break;
4176            };
4177
4178            self.verify_writer_lease()?;
4179            let total_changes_before = conn.total_changes();
4180            let tx = conn.transaction()?;
4181            {
4182                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4183                let mut offset = 0;
4184                while offset < staged.len() {
4185                    let caller_file = staged[offset].raw.caller_file.clone();
4186                    let end = staged[offset..]
4187                        .iter()
4188                        .position(|entry| entry.raw.caller_file != caller_file)
4189                        .map(|relative| offset + relative)
4190                        .unwrap_or(staged.len());
4191                    let caller_extract = build_file_extract(
4192                        &self.project_root,
4193                        &self.project_root.join(&caller_file),
4194                    );
4195                    if let Ok(caller_extract) = caller_extract {
4196                        let index = DiskProjectIndex {
4197                            project_root: &self.project_root,
4198                            conn: &tx,
4199                            caller_file: &caller_file,
4200                            caller_data: &caller_extract.data,
4201                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
4202                            module_resolution_memo,
4203                            file_index_memo: RefCell::new(HashMap::new()),
4204                            module_parent_memo: RefCell::new(HashMap::new()),
4205                            memoize_resolver_indexes,
4206                        };
4207                        for staged_ref in &staged[offset..end] {
4208                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
4209                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4210                        }
4211                    } else {
4212                        for staged_ref in &staged[offset..end] {
4213                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
4214                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
4215                        }
4216                    }
4217                    offset = end;
4218                }
4219            }
4220            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
4221            tx.commit()?;
4222            self.record_commit(total_changes_before, &conn);
4223            resolve_cursor = last_rowid;
4224            resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
4225            ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4226        }
4227
4228        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4229        note_cold_build_phase("publication");
4230        self.verify_writer_lease()?;
4231        let total_changes_before = conn.total_changes();
4232        let tx = conn.transaction()?;
4233        let _supplemental_edge_count =
4234            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
4235        set_meta_ready(&tx, true)?;
4236        set_staged_build_phase(&tx, "ready")?;
4237        tx.execute("DELETE FROM staging_file_inventory", [])?;
4238        tx.execute("DELETE FROM staging_ref_context", [])?;
4239        bump_projection_write_revision(&tx)?;
4240        tx.commit()?;
4241        self.record_commit(total_changes_before, &conn);
4242
4243        cold_build_stats_from_connection(&conn, started)
4244    }
4245
4246    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
4247        self.refresh_files_with_workspace_crate_prefix_cache(
4248            changed_files,
4249            WorkspaceCratePrefixCache::default(),
4250        )
4251    }
4252
4253    fn refresh_files_with_workspace_crate_prefix_cache(
4254        &self,
4255        changed_files: &[PathBuf],
4256        workspace_crate_prefixes: WorkspaceCratePrefixCache,
4257    ) -> Result<IncrementalStats> {
4258        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4259            changed_files,
4260            workspace_crate_prefixes,
4261        )?;
4262        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
4263            eprintln!("refresh_files phases: {}", profile.report());
4264        }
4265        Ok(stats)
4266    }
4267
4268    /// Run an incremental refresh and return phase timings for an offline store copy.
4269    #[doc(hidden)]
4270    pub fn refresh_files_profiled(
4271        &self,
4272        changed_files: &[PathBuf],
4273    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4274        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4275            changed_files,
4276            WorkspaceCratePrefixCache::default(),
4277        )
4278    }
4279
4280    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
4281        &self,
4282        changed_files: &[PathBuf],
4283        workspace_crate_prefixes: WorkspaceCratePrefixCache,
4284    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4285        let _io = crate::views::io::Window::event("legacy_callgraph_refresh", &self.project_root);
4286        let total_started = Instant::now();
4287        let mut profile = RefreshFilesProfile::default();
4288        self.verify_writer_lease()?;
4289        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4290        ensure_database_ready(&conn)?;
4291        let total_changes_before = conn.total_changes();
4292        let mut changed = Vec::new();
4293        let mut surface_changed = BTreeSet::new();
4294        let mut deleted = BTreeSet::new();
4295        let mut own_refresh = BTreeSet::new();
4296        let mut candidate_own_refresh = BTreeSet::new();
4297        let mut confirmed_fresh = BTreeSet::new();
4298        let mut unchanged_extracts = 0usize;
4299        let mut selected_ref_ids = BTreeSet::new();
4300        let mut selected_refs_by_caller = BTreeMap::new();
4301        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
4302        let mut fresh_metadata = BTreeMap::new();
4303
4304        // Watchers cannot be the only source of deletions: a root can be
4305        // unbound, idle, or restarted while a delete occurs, so that event is
4306        // never delivered. Every refresh therefore resolves stale rows that a
4307        // strict stat proves are now absent, even when this batch is empty or
4308        // none of its paths are adopted.
4309        for rel_path in stale_backend_file_paths(&conn, &self.project_root, true)? {
4310            if stale_path_status(&self.project_root, &rel_path) != StalePathStatus::Absent {
4311                continue;
4312            }
4313            if deleted.insert(rel_path.clone()) && load_file_row(&conn, &rel_path)?.is_some() {
4314                surface_changed.insert(rel_path.clone());
4315                let started = Instant::now();
4316                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4317                profile.dependency_selection += started.elapsed();
4318                record_dependent_refs(
4319                    &mut selected_ref_ids,
4320                    &mut selected_refs_by_caller,
4321                    dependent_refs,
4322                );
4323            }
4324        }
4325
4326        for input in changed_files {
4327            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
4328            {
4329                Ok(path) => path,
4330                Err(error) => {
4331                    record_path_identity_mismatch(&conn, &error)?;
4332                    return Err(error);
4333                }
4334            };
4335            changed.push(rel_path.clone());
4336            let old_row = load_file_row(&conn, &rel_path)?;
4337            if !abs_path.exists() {
4338                if old_row.is_some() && deleted.insert(rel_path.clone()) {
4339                    surface_changed.insert(rel_path.clone());
4340                    let started = Instant::now();
4341                    let dependent_refs =
4342                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4343                    profile.dependency_selection += started.elapsed();
4344                    record_dependent_refs(
4345                        &mut selected_ref_ids,
4346                        &mut selected_refs_by_caller,
4347                        dependent_refs,
4348                    );
4349                }
4350                continue;
4351            }
4352
4353            if let Some(row) = &old_row {
4354                match cache_freshness::verify_file(&abs_path, &row.freshness) {
4355                    FreshnessVerdict::HotFresh => {
4356                        // Content still matches the stored graph. A prior failed
4357                        // refresh may have left backend_file_state='stale' without
4358                        // changing bytes; skip the extract but still clear that
4359                        // leftover so dead-code projection can use this store.
4360                        confirmed_fresh.insert(rel_path.clone());
4361                        continue;
4362                    }
4363                    FreshnessVerdict::ContentFresh {
4364                        new_mtime,
4365                        new_size,
4366                    } => {
4367                        fresh_metadata.insert(
4368                            rel_path.clone(),
4369                            FileFreshness {
4370                                content_hash: row.freshness.content_hash,
4371                                mtime: new_mtime,
4372                                size: new_size,
4373                            },
4374                        );
4375                        continue;
4376                    }
4377                    FreshnessVerdict::Deleted => {
4378                        if deleted.insert(rel_path.clone()) {
4379                            surface_changed.insert(rel_path.clone());
4380                            let started = Instant::now();
4381                            let dependent_refs =
4382                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4383                            profile.dependency_selection += started.elapsed();
4384                            record_dependent_refs(
4385                                &mut selected_ref_ids,
4386                                &mut selected_refs_by_caller,
4387                                dependent_refs,
4388                            );
4389                        }
4390                        continue;
4391                    }
4392                    FreshnessVerdict::Stale => {}
4393                }
4394            }
4395
4396            let started = Instant::now();
4397            let extract = build_file_extract(&self.project_root, &abs_path)?;
4398            profile.parse += started.elapsed();
4399            let surface_is_changed = old_row
4400                .as_ref()
4401                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
4402                .unwrap_or(true);
4403            if surface_is_changed {
4404                surface_changed.insert(rel_path.clone());
4405                let started = Instant::now();
4406                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4407                profile.dependency_selection += started.elapsed();
4408                record_dependent_refs(
4409                    &mut selected_ref_ids,
4410                    &mut selected_refs_by_caller,
4411                    dependent_refs,
4412                );
4413            }
4414            candidate_own_refresh.insert(rel_path.clone());
4415            changed_extracts.insert(rel_path, extract);
4416        }
4417
4418        let dependency_selected_refs = selected_ref_ids.len();
4419        let mut touched_callers: BTreeSet<String> =
4420            selected_refs_by_caller.keys().cloned().collect();
4421        touched_callers.extend(candidate_own_refresh.iter().cloned());
4422
4423        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
4424        for rel_path in &touched_callers {
4425            if deleted.contains(rel_path) {
4426                continue;
4427            }
4428            if let Some(extract) = changed_extracts.get(rel_path) {
4429                caller_extracts.insert(rel_path.clone(), extract.clone());
4430                continue;
4431            }
4432            let abs_path = self.project_root.join(rel_path);
4433            if abs_path.exists() {
4434                let started = Instant::now();
4435                let extract = build_file_extract(&self.project_root, &abs_path)?;
4436                profile.dependent_parse += started.elapsed();
4437                caller_extracts.insert(rel_path.clone(), extract);
4438            }
4439        }
4440
4441        let mut projection_callers = touched_callers.clone();
4442        projection_callers.extend(deleted.iter().cloned());
4443        for file in touched_callers.iter().chain(deleted.iter()) {
4444            dead_code_projection::extend_projection_dependents(
4445                &conn,
4446                file,
4447                &mut projection_callers,
4448            )?;
4449        }
4450
4451        let tx = conn.transaction()?;
4452        for (rel_path, freshness) in fresh_metadata {
4453            update_file_fresh_metadata(
4454                &tx,
4455                &self.project_root,
4456                &rel_path,
4457                &freshness.content_hash,
4458                freshness.mtime,
4459                freshness.size,
4460            )?;
4461        }
4462        for rel_path in &confirmed_fresh {
4463            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4464        }
4465        for rel_path in &deleted {
4466            let started = Instant::now();
4467            delete_file_rows(&tx, rel_path)?;
4468            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4469            profile.row_deletes += started.elapsed();
4470        }
4471
4472        // Already-fresh inputs have no callers to resolve. Backend freshness
4473        // writes do not change projection inputs, so only deletions invalidate
4474        // the retained snapshot on this path.
4475        if caller_extracts.is_empty() {
4476            let wrote_rows = tx.total_changes() != total_changes_before;
4477            if !deleted.is_empty() {
4478                dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4479            }
4480            let started = Instant::now();
4481            commit_incremental_if_current(tx)?;
4482            if wrote_rows {
4483                self.record_commit(total_changes_before, &conn);
4484            }
4485            profile.commit += started.elapsed();
4486            profile.total = total_started.elapsed();
4487            return Ok((
4488                IncrementalStats {
4489                    changed_files: changed,
4490                    surface_changed: surface_changed.into_iter().collect(),
4491                    deleted_files: deleted.into_iter().collect(),
4492                    dependency_selected_refs,
4493                    refreshed_own_files: 0,
4494                    unchanged_extract_files: 0,
4495                },
4496                profile,
4497            ));
4498        }
4499
4500        let started = Instant::now();
4501        profile.index_loads += 1;
4502        let index = ProjectIndex::from_db_and_callers(
4503            &tx,
4504            &self.project_root,
4505            &caller_extracts,
4506            workspace_crate_prefixes,
4507        )?;
4508        profile.index_load += started.elapsed();
4509
4510        let workspace_root = self.project_root.display().to_string();
4511        {
4512            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4513            for rel_path in &candidate_own_refresh {
4514                let Some(extract) = changed_extracts.get(rel_path) else {
4515                    continue;
4516                };
4517                if stored_extract_matches(&tx, rel_path, extract, &index)? {
4518                    unchanged_extracts += 1;
4519                    update_file_fresh_metadata(
4520                        &tx,
4521                        &self.project_root,
4522                        rel_path,
4523                        &extract.freshness.content_hash,
4524                        extract.freshness.mtime,
4525                        extract.freshness.size,
4526                    )?;
4527                    continue;
4528                }
4529
4530                own_refresh.insert(rel_path.clone());
4531                let started = Instant::now();
4532                delete_file_rows(&tx, rel_path)?;
4533                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4534                profile.row_deletes += started.elapsed();
4535                let started = Instant::now();
4536                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4537                profile.row_inserts += started.elapsed();
4538            }
4539
4540            let dependency_callers = touched_callers
4541                .iter()
4542                .filter(|rel_path| {
4543                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4544                })
4545                .cloned()
4546                .collect::<Vec<_>>();
4547            for rel_path in dependency_callers {
4548                let Some(extract) = caller_extracts.get(&rel_path) else {
4549                    continue;
4550                };
4551                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4552                    continue;
4553                }
4554
4555                own_refresh.insert(rel_path.clone());
4556                let started = Instant::now();
4557                delete_file_rows(&tx, &rel_path)?;
4558                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4559                profile.row_deletes += started.elapsed();
4560                let started = Instant::now();
4561                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4562                profile.row_inserts += started.elapsed();
4563            }
4564            let started = Instant::now();
4565            for rel_path in &touched_callers {
4566                if deleted.contains(rel_path) {
4567                    continue;
4568                }
4569                let Some(extract) = caller_extracts.get(rel_path) else {
4570                    continue;
4571                };
4572                if own_refresh.contains(rel_path) {
4573                    delete_refs_for_caller(&tx, rel_path)?;
4574                    for raw_ref in &extract.raw_refs {
4575                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4576                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4577                    }
4578                    continue;
4579                }
4580
4581                let selected_for_caller = selected_refs_by_caller
4582                    .get(rel_path)
4583                    .cloned()
4584                    .unwrap_or_default();
4585                delete_ref_ids(&tx, &selected_for_caller)?;
4586                for raw_ref in &extract.raw_refs {
4587                    if selected_for_caller.contains(&raw_ref.ref_id) {
4588                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4589                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4590                    }
4591                }
4592            }
4593            profile.ref_resolution += started.elapsed();
4594        }
4595
4596        let started = Instant::now();
4597        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4598        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4599        profile.method_dispatch += started.elapsed();
4600
4601        // Freshness metadata is not an input to the projection. Only changed
4602        // graph rows need a new revision and a corresponding caller delta.
4603        if !own_refresh.is_empty() || !selected_ref_ids.is_empty() || !deleted.is_empty() {
4604            dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4605        }
4606        let started = Instant::now();
4607        commit_incremental_if_current(tx)?;
4608        self.record_commit(total_changes_before, &conn);
4609        profile.commit += started.elapsed();
4610        profile.total = total_started.elapsed();
4611        Ok((
4612            IncrementalStats {
4613                changed_files: changed,
4614                surface_changed: surface_changed.into_iter().collect(),
4615                deleted_files: deleted.into_iter().collect(),
4616                dependency_selected_refs,
4617                refreshed_own_files: own_refresh.len(),
4618                unchanged_extract_files: unchanged_extracts,
4619            },
4620            profile,
4621        ))
4622    }
4623
4624    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4625        self.cold_build(current_files)
4626    }
4627
4628    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4629        self.verify_writer_lease()?;
4630        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4631        let total_changes_before = conn.total_changes();
4632        let tx = conn.transaction()?;
4633        let mut marked = Vec::new();
4634        for path in files {
4635            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4636                Ok(path) => path,
4637                Err(error) => {
4638                    drop(tx);
4639                    record_path_identity_mismatch(&conn, &error)?;
4640                    return Err(error);
4641                }
4642            };
4643            let freshness = cache_freshness::collect(&abs_path).ok();
4644            mark_backend_state(
4645                &tx,
4646                &self.project_root,
4647                &rel_path,
4648                freshness.as_ref().map(|freshness| &freshness.content_hash),
4649                "stale",
4650            )?;
4651            marked.push(rel_path);
4652        }
4653        // A stale marker blocks projection until refresh but does not mutate graph
4654        // rows. Only a refresh that changes graph rows advances the revision and
4655        // writes the matching changed-file journal entry.
4656        tx.commit()?;
4657        self.record_commit(total_changes_before, &conn);
4658        marked.sort();
4659        marked.dedup();
4660        Ok(marked)
4661    }
4662
4663    pub fn stale_files(&self) -> Result<Vec<String>> {
4664        self.refresh_read_marker()?;
4665        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4666        stale_backend_file_paths(&conn, &self.project_root, true)
4667    }
4668
4669    pub fn stale_path_census(&self) -> Result<StalePathCensus> {
4670        self.refresh_read_marker()?;
4671        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4672        stale_path_census(&conn, &self.project_root)
4673    }
4674
4675    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4676        self.refresh_read_marker()?;
4677        let rel_path = relative_path(
4678            &self.project_root,
4679            &normalize_file_path(&self.project_root, file)?,
4680        );
4681        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4682        conn.query_row(
4683            "SELECT status FROM backend_file_state
4684             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4685             ORDER BY updated_at DESC LIMIT 1",
4686            params![
4687                BACKEND_TREESITTER,
4688                self.project_root.display().to_string(),
4689                rel_path
4690            ],
4691            |row| row.get(0),
4692        )
4693        .optional()
4694        .map_err(Into::into)
4695    }
4696
4697    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4698        self.refresh_read_marker()?;
4699        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4700        self.ensure_ready(&conn)?;
4701        edge_snapshot_with_conn(&conn)
4702    }
4703
4704    pub fn indexed_file_count(&self) -> Result<usize> {
4705        self.refresh_read_marker()?;
4706        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4707        self.ensure_ready(&conn)?;
4708        indexed_file_count(&conn)
4709    }
4710
4711    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4712        self.refresh_read_marker()?;
4713        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4714        let rel_path = relative_path(&self.project_root, &abs_path);
4715        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4716        self.ensure_ready(&conn)?;
4717        resolve_node_for_rel(&conn, &rel_path, symbol)
4718    }
4719
4720    /// Return all positional nodes matching a legacy symbol query in a file.
4721    ///
4722    /// Consumers that need legacy compatibility can collapse these by
4723    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
4724    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4725        self.refresh_read_marker()?;
4726        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4727        let rel_path = relative_path(&self.project_root, &abs_path);
4728        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4729        self.ensure_ready(&conn)?;
4730        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4731    }
4732
4733    /// Return all positional nodes matching a symbol query anywhere in the store.
4734    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4735        self.refresh_read_marker()?;
4736        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4737        self.ensure_ready(&conn)?;
4738        nodes_matching_symbol(&conn, symbol)
4739    }
4740
4741    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
4742    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4743        self.refresh_read_marker()?;
4744        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4745        let rel_path = relative_path(&self.project_root, &abs_path);
4746        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4747        self.ensure_ready(&conn)?;
4748        direct_callers_for_tuple(&conn, &rel_path, symbol)
4749    }
4750
4751    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
4752    pub fn direct_callers_for_symbols(
4753        &self,
4754        targets: &[(String, String)],
4755    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4756        if targets.is_empty() {
4757            return Ok(HashMap::new());
4758        }
4759        self.refresh_read_marker()?;
4760        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4761        self.ensure_ready(&conn)?;
4762        direct_callers_for_tuples(&conn, targets)
4763    }
4764
4765    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
4766    pub fn direct_caller_counts_of(
4767        &self,
4768        targets: &[(String, String)],
4769    ) -> Result<HashMap<(String, String), usize>> {
4770        if targets.is_empty() {
4771            return Ok(HashMap::new());
4772        }
4773        self.refresh_read_marker()?;
4774        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4775        self.ensure_ready(&conn)?;
4776        direct_caller_counts_for_tuples(&conn, targets)
4777    }
4778
4779    pub fn callers_of(
4780        &self,
4781        file_rel: &Path,
4782        symbol: &str,
4783        depth: usize,
4784    ) -> Result<StoreCallersResult> {
4785        let target = self.node_for(file_rel, symbol)?;
4786        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4787        self.ensure_ready(&conn)?;
4788        let effective_depth = depth.max(1);
4789        let mut visited = HashSet::new();
4790        let mut callers = Vec::new();
4791        let mut depth_limited = false;
4792        let mut truncated = 0usize;
4793        collect_callers_recursive(
4794            &conn,
4795            &target.file,
4796            &target.symbol,
4797            effective_depth,
4798            0,
4799            &mut visited,
4800            &mut callers,
4801            &mut depth_limited,
4802            &mut truncated,
4803        )?;
4804        Ok(StoreCallersResult {
4805            target,
4806            callers,
4807            scanned_files: indexed_file_count(&conn)?,
4808            depth_limited,
4809            truncated,
4810        })
4811    }
4812
4813    pub fn impact_of(
4814        &self,
4815        file_rel: &Path,
4816        symbol: &str,
4817        depth: usize,
4818    ) -> Result<StoreImpactResult> {
4819        let callers = self.callers_of(file_rel, symbol, depth)?;
4820        let target_parameters = callers
4821            .target
4822            .signature
4823            .as_deref()
4824            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4825            .unwrap_or_default();
4826        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4827        for site in &callers.callers {
4828            source_lines_by_file
4829                .entry(site.caller.file.clone())
4830                .or_insert_with(|| {
4831                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4832                });
4833        }
4834        let enriched = callers
4835            .callers
4836            .iter()
4837            .map(|site| StoreImpactCaller {
4838                site: site.clone(),
4839                signature: site.caller.signature.clone(),
4840                is_entry_point: site.caller.is_entry_point,
4841                call_expression: source_lines_by_file
4842                    .get(&site.caller.file)
4843                    .and_then(|lines| lines.as_ref())
4844                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4845                    .cloned(),
4846                parameters: site
4847                    .caller
4848                    .signature
4849                    .as_deref()
4850                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4851                    .unwrap_or_default(),
4852            })
4853            .collect();
4854        Ok(StoreImpactResult {
4855            target: callers.target,
4856            parameters: target_parameters,
4857            callers: enriched,
4858            depth_limited: callers.depth_limited,
4859            truncated: callers.truncated,
4860        })
4861    }
4862
4863    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4864        self.refresh_read_marker()?;
4865        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4866        self.ensure_ready(&conn)?;
4867        outgoing_calls_for_node(&conn, node)
4868    }
4869
4870    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4871    pub fn outgoing_calls_for_symbols(
4872        &self,
4873        sources: &[(String, String)],
4874    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4875        if sources.is_empty() {
4876            return Ok(HashMap::new());
4877        }
4878        self.refresh_read_marker()?;
4879        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4880        self.ensure_ready(&conn)?;
4881        outgoing_calls_for_symbol_tuples(&conn, sources)
4882    }
4883
4884    /// Return resolved direct self-call refs suppressed from the general edge table.
4885    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4886        self.refresh_read_marker()?;
4887        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4888        self.ensure_ready(&conn)?;
4889        resolved_self_calls_for_node(&conn, node)
4890    }
4891
4892    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4893        self.refresh_read_marker()?;
4894        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4895        self.ensure_ready(&conn)?;
4896        unresolved_calls_for_node(&conn, node)
4897    }
4898
4899    pub fn call_tree(
4900        &self,
4901        file_rel: &Path,
4902        symbol: &str,
4903        max_depth: usize,
4904    ) -> Result<callgraph::CallTreeNode> {
4905        let node = self.node_for(file_rel, symbol)?;
4906        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4907        self.ensure_ready(&conn)?;
4908        let mut visited = HashSet::new();
4909        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4910    }
4911
4912    pub fn trace_to(
4913        &self,
4914        file_rel: &Path,
4915        symbol: &str,
4916        max_depth: usize,
4917    ) -> Result<callgraph::TraceToResult> {
4918        let target = self.node_for(file_rel, symbol)?;
4919        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4920        self.ensure_ready(&conn)?;
4921        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4922
4923        #[derive(Clone)]
4924        struct PathElem {
4925            node: StoreNode,
4926        }
4927
4928        let initial = vec![PathElem {
4929            node: target.clone(),
4930        }];
4931        let mut complete_paths = Vec::new();
4932        if target.is_entry_point {
4933            complete_paths.push(initial.clone());
4934        }
4935
4936        let mut queue = vec![(initial, 0usize)];
4937        let mut max_depth_reached = false;
4938        let mut truncated_paths = 0usize;
4939
4940        while let Some((path, depth)) = queue.pop() {
4941            if depth >= effective_max {
4942                max_depth_reached = true;
4943                continue;
4944            }
4945            let Some(current) = path.last() else {
4946                continue;
4947            };
4948            let callers =
4949                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4950            if callers.is_empty() {
4951                if path.len() > 1 {
4952                    truncated_paths += 1;
4953                }
4954                continue;
4955            }
4956
4957            let mut has_new_path = false;
4958            for site in callers {
4959                if path.iter().any(|elem| {
4960                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4961                }) {
4962                    continue;
4963                }
4964                has_new_path = true;
4965                let mut new_path = path.clone();
4966                new_path.push(PathElem {
4967                    node: site.caller.clone(),
4968                });
4969                if site.caller.is_entry_point {
4970                    complete_paths.push(new_path.clone());
4971                }
4972                queue.push((new_path, depth + 1));
4973            }
4974            if !has_new_path && path.len() > 1 {
4975                truncated_paths += 1;
4976            }
4977        }
4978
4979        let mut paths: Vec<callgraph::TracePath> = complete_paths
4980            .into_iter()
4981            .map(|mut elems| {
4982                elems.reverse();
4983                let hops = elems
4984                    .iter()
4985                    .enumerate()
4986                    .map(|(index, elem)| callgraph::TraceHop {
4987                        symbol: elem.node.symbol.clone(),
4988                        file: elem.node.file.clone(),
4989                        line: elem.node.line,
4990                        signature: elem.node.signature.clone(),
4991                        is_entry_point: index == 0 && elem.node.is_entry_point,
4992                    })
4993                    .collect();
4994                callgraph::TracePath { hops }
4995            })
4996            .collect();
4997        paths.sort_by(|left, right| {
4998            let left_entry = left
4999                .hops
5000                .first()
5001                .map(|hop| hop.symbol.as_str())
5002                .unwrap_or("");
5003            let right_entry = right
5004                .hops
5005                .first()
5006                .map(|hop| hop.symbol.as_str())
5007                .unwrap_or("");
5008            left_entry
5009                .cmp(right_entry)
5010                .then(left.hops.len().cmp(&right.hops.len()))
5011        });
5012        let entry_points_found = paths
5013            .iter()
5014            .filter_map(|path| path.hops.first())
5015            .filter(|hop| hop.is_entry_point)
5016            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
5017            .collect::<HashSet<_>>()
5018            .len();
5019
5020        Ok(callgraph::TraceToResult {
5021            target_symbol: target.symbol,
5022            target_file: target.file,
5023            total_paths: paths.len(),
5024            paths,
5025            entry_points_found,
5026            max_depth_reached,
5027            truncated_paths,
5028        })
5029    }
5030
5031    pub fn trace_to_symbol_candidates(
5032        &self,
5033        to_symbol: &str,
5034    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
5035        self.refresh_read_marker()?;
5036        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5037        self.ensure_ready(&conn)?;
5038        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
5039        for node in nodes_matching_symbol(&conn, to_symbol)? {
5040            candidates_by_file
5041                .entry(node.file)
5042                .and_modify(|line| *line = (*line).min(node.line))
5043                .or_insert(node.line);
5044        }
5045        let mut candidates: Vec<_> = candidates_by_file
5046            .into_iter()
5047            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
5048            .collect();
5049        candidates
5050            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
5051        Ok(candidates)
5052    }
5053
5054    pub fn trace_to_symbol(
5055        &self,
5056        file_rel: &Path,
5057        symbol: &str,
5058        to_symbol: &str,
5059        to_file: Option<&Path>,
5060        max_depth: usize,
5061    ) -> Result<callgraph::TraceToSymbolResult> {
5062        let origin = self.node_for(file_rel, symbol)?;
5063        let target_file = to_file
5064            .map(|path| normalize_file_path(&self.project_root, path))
5065            .transpose()?
5066            .map(|path| relative_path(&self.project_root, &path));
5067        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5068        self.ensure_ready(&conn)?;
5069        let effective_max = if max_depth == 0 {
5070            10
5071        } else {
5072            max_depth.min(16)
5073        };
5074
5075        let start_hop = trace_to_symbol_hop(&origin);
5076        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
5077            return Ok(callgraph::TraceToSymbolResult {
5078                path: Some(vec![start_hop]),
5079                complete: true,
5080                reason: None,
5081            });
5082        }
5083
5084        let mut queue = VecDeque::new();
5085        queue.push_back((origin.clone(), vec![start_hop], 0usize));
5086        let mut visited = HashSet::new();
5087        visited.insert((origin.file.clone(), origin.symbol.clone()));
5088        let mut max_depth_exhausted = false;
5089
5090        while let Some((current, path, depth)) = queue.pop_front() {
5091            let callees = outgoing_calls_for_node(&conn, &current)?
5092                .into_iter()
5093                .filter_map(|site| site.target)
5094                .collect::<Vec<_>>();
5095
5096            if depth >= effective_max {
5097                if callees
5098                    .iter()
5099                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
5100                {
5101                    max_depth_exhausted = true;
5102                }
5103                continue;
5104            }
5105
5106            for callee in callees {
5107                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
5108                    continue;
5109                }
5110                let mut next_path = path.clone();
5111                next_path.push(trace_to_symbol_hop(&callee));
5112                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
5113                    return Ok(callgraph::TraceToSymbolResult {
5114                        path: Some(next_path),
5115                        complete: true,
5116                        reason: None,
5117                    });
5118                }
5119                queue.push_back((callee, next_path, depth + 1));
5120            }
5121        }
5122
5123        if max_depth_exhausted {
5124            Ok(callgraph::TraceToSymbolResult {
5125                path: None,
5126                complete: false,
5127                reason: Some("max_depth_exhausted".to_string()),
5128            })
5129        } else {
5130            Ok(callgraph::TraceToSymbolResult {
5131                path: None,
5132                complete: true,
5133                reason: Some("no_path_found".to_string()),
5134            })
5135        }
5136    }
5137}
5138
5139impl ReadonlyCallGraphStore {
5140    pub(crate) fn open_manifest_view(
5141        project_root: PathBuf,
5142        family: String,
5143        view_dir: PathBuf,
5144        generation: &str,
5145        pin: Option<Arc<crate::pins::QueryPin>>,
5146    ) -> Result<Self> {
5147        let generation_path = crate::views::resolve_derived_path(&view_dir, generation)
5148            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
5149        // Older publications used one checkout-wide database. Keep them readable
5150        // until the first generation-owned publication replaces their handle.
5151        let sqlite_path = if generation_path.is_file() {
5152            generation_path
5153        } else {
5154            view_dir.join("derived.sqlite")
5155        };
5156        let conn = open_readonly_connection(&sqlite_path)?;
5157        ensure_database_ready(&conn)?;
5158        let mut inner = CallGraphStore::from_connection(
5159            project_root,
5160            family,
5161            sqlite_path,
5162            view_dir,
5163            false,
5164            None,
5165            None,
5166            None,
5167            conn,
5168        );
5169        inner.manifest_view = true;
5170        inner.database_ready.store(true, AtomicOrdering::Release);
5171        Ok(Self {
5172            inner,
5173            _view_pin: pin,
5174        })
5175    }
5176
5177    pub fn reader_kind(&self) -> &'static str {
5178        if self.inner.manifest_view {
5179            "view"
5180        } else {
5181            "legacy"
5182        }
5183    }
5184
5185    fn from_inner(inner: CallGraphStore) -> Self {
5186        Self {
5187            inner,
5188            _view_pin: None,
5189        }
5190    }
5191
5192    pub fn project_root(&self) -> &Path {
5193        self.inner.project_root()
5194    }
5195
5196    pub fn project_key(&self) -> &str {
5197        self.inner.project_key()
5198    }
5199
5200    pub fn sqlite_path(&self) -> &Path {
5201        self.inner.sqlite_path()
5202    }
5203
5204    pub fn stale_files(&self) -> Result<Vec<String>> {
5205        self.inner.stale_files()
5206    }
5207
5208    pub fn stale_path_census(&self) -> Result<StalePathCensus> {
5209        self.inner.stale_path_census()
5210    }
5211
5212    /// Read stale-path health from this resident connection without opening a
5213    /// second SQLite handle or waiting behind an active store operation.
5214    pub fn try_stale_path_census(&self) -> Result<Option<StalePathCensus>> {
5215        let conn = match self.inner.conn.try_lock() {
5216            Ok(conn) => conn,
5217            Err(std::sync::TryLockError::WouldBlock) => return Ok(None),
5218            Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(),
5219        };
5220        stale_path_census(&conn, &self.inner.project_root).map(Some)
5221    }
5222
5223    pub(crate) fn projection_generation(&self) -> Option<&str> {
5224        self.inner.projection_generation()
5225    }
5226
5227    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
5228        self.inner.projection_write_revision()
5229    }
5230
5231    /// Report the open generation handle. SQLite-owned allocations are measured
5232    /// once by the process-wide SQLite allocator counters.
5233    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
5234        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
5235    }
5236
5237    /// Whether this reader is temporarily serving a legacy harness partition.
5238    pub fn is_legacy_fallback(&self) -> bool {
5239        self.inner.is_legacy_fallback()
5240    }
5241
5242    pub fn is_current(&self) -> bool {
5243        self.inner.is_current()
5244    }
5245
5246    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5247        self.inner.edge_snapshot()
5248    }
5249
5250    pub fn indexed_file_count(&self) -> Result<usize> {
5251        self.inner.indexed_file_count()
5252    }
5253
5254    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5255        self.inner.node_for(file_rel, symbol)
5256    }
5257
5258    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5259        self.inner.nodes_for(file_rel, symbol)
5260    }
5261
5262    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5263        self.inner.nodes_matching(symbol)
5264    }
5265
5266    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5267        self.inner.direct_callers_of(file_rel, symbol)
5268    }
5269
5270    pub fn direct_callers_for_symbols(
5271        &self,
5272        targets: &[(String, String)],
5273    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5274        self.inner.direct_callers_for_symbols(targets)
5275    }
5276
5277    pub fn direct_caller_counts_of(
5278        &self,
5279        targets: &[(String, String)],
5280    ) -> Result<HashMap<(String, String), usize>> {
5281        self.inner.direct_caller_counts_of(targets)
5282    }
5283
5284    pub fn callers_of(
5285        &self,
5286        file_rel: &Path,
5287        symbol: &str,
5288        depth: usize,
5289    ) -> Result<StoreCallersResult> {
5290        self.inner.callers_of(file_rel, symbol, depth)
5291    }
5292
5293    pub fn impact_of(
5294        &self,
5295        file_rel: &Path,
5296        symbol: &str,
5297        depth: usize,
5298    ) -> Result<StoreImpactResult> {
5299        self.inner.impact_of(file_rel, symbol, depth)
5300    }
5301
5302    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5303        self.inner.outgoing_calls_of(node)
5304    }
5305
5306    pub fn outgoing_calls_for_symbols(
5307        &self,
5308        sources: &[(String, String)],
5309    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5310        self.inner.outgoing_calls_for_symbols(sources)
5311    }
5312
5313    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5314        self.inner.resolved_self_calls_of(node)
5315    }
5316
5317    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5318        self.inner.unresolved_calls_of(node)
5319    }
5320
5321    pub fn call_tree(
5322        &self,
5323        file_rel: &Path,
5324        symbol: &str,
5325        depth: usize,
5326    ) -> Result<callgraph::CallTreeNode> {
5327        self.inner.call_tree(file_rel, symbol, depth)
5328    }
5329
5330    pub fn trace_to(
5331        &self,
5332        file_rel: &Path,
5333        symbol: &str,
5334        max_depth: usize,
5335    ) -> Result<callgraph::TraceToResult> {
5336        self.inner.trace_to(file_rel, symbol, max_depth)
5337    }
5338
5339    pub fn trace_to_symbol_candidates(
5340        &self,
5341        to_symbol: &str,
5342    ) -> Result<Vec<TraceToSymbolCandidate>> {
5343        self.inner.trace_to_symbol_candidates(to_symbol)
5344    }
5345
5346    pub fn trace_to_symbol(
5347        &self,
5348        file_rel: &Path,
5349        symbol: &str,
5350        to_symbol: &str,
5351        to_file: Option<&Path>,
5352        max_depth: usize,
5353    ) -> Result<callgraph::TraceToSymbolResult> {
5354        self.inner
5355            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5356    }
5357}
5358
5359impl CallGraphRead for CallGraphStore {
5360    fn project_root(&self) -> &Path {
5361        CallGraphStore::project_root(self)
5362    }
5363    fn project_key(&self) -> &str {
5364        CallGraphStore::project_key(self)
5365    }
5366    fn sqlite_path(&self) -> &Path {
5367        CallGraphStore::sqlite_path(self)
5368    }
5369    fn is_current(&self) -> bool {
5370        CallGraphStore::is_current(self)
5371    }
5372    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5373        CallGraphStore::edge_snapshot(self)
5374    }
5375    fn indexed_file_count(&self) -> Result<usize> {
5376        CallGraphStore::indexed_file_count(self)
5377    }
5378    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5379        CallGraphStore::node_for(self, file_rel, symbol)
5380    }
5381    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5382        CallGraphStore::nodes_for(self, file_rel, symbol)
5383    }
5384    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5385        CallGraphStore::nodes_matching(self, symbol)
5386    }
5387    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5388        CallGraphStore::direct_callers_of(self, file_rel, symbol)
5389    }
5390    fn direct_callers_for_symbols(
5391        &self,
5392        targets: &[(String, String)],
5393    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5394        CallGraphStore::direct_callers_for_symbols(self, targets)
5395    }
5396    fn direct_caller_counts_of(
5397        &self,
5398        targets: &[(String, String)],
5399    ) -> Result<HashMap<(String, String), usize>> {
5400        CallGraphStore::direct_caller_counts_of(self, targets)
5401    }
5402    fn callers_of(
5403        &self,
5404        file_rel: &Path,
5405        symbol: &str,
5406        depth: usize,
5407    ) -> Result<StoreCallersResult> {
5408        CallGraphStore::callers_of(self, file_rel, symbol, depth)
5409    }
5410    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5411        CallGraphStore::impact_of(self, file_rel, symbol, depth)
5412    }
5413    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5414        CallGraphStore::outgoing_calls_of(self, node)
5415    }
5416    fn outgoing_calls_for_symbols(
5417        &self,
5418        sources: &[(String, String)],
5419    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5420        CallGraphStore::outgoing_calls_for_symbols(self, sources)
5421    }
5422    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5423        CallGraphStore::resolved_self_calls_of(self, node)
5424    }
5425    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5426        CallGraphStore::unresolved_calls_of(self, node)
5427    }
5428    fn call_tree(
5429        &self,
5430        file_rel: &Path,
5431        symbol: &str,
5432        depth: usize,
5433    ) -> Result<callgraph::CallTreeNode> {
5434        CallGraphStore::call_tree(self, file_rel, symbol, depth)
5435    }
5436    fn trace_to(
5437        &self,
5438        file_rel: &Path,
5439        symbol: &str,
5440        max_depth: usize,
5441    ) -> Result<callgraph::TraceToResult> {
5442        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
5443    }
5444    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5445        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
5446    }
5447    fn trace_to_symbol(
5448        &self,
5449        file_rel: &Path,
5450        symbol: &str,
5451        to_symbol: &str,
5452        to_file: Option<&Path>,
5453        max_depth: usize,
5454    ) -> Result<callgraph::TraceToSymbolResult> {
5455        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
5456    }
5457}
5458
5459impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
5460    fn project_root(&self) -> &Path {
5461        (**self).project_root()
5462    }
5463    fn project_key(&self) -> &str {
5464        (**self).project_key()
5465    }
5466    fn sqlite_path(&self) -> &Path {
5467        (**self).sqlite_path()
5468    }
5469    fn is_current(&self) -> bool {
5470        (**self).is_current()
5471    }
5472    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5473        (**self).edge_snapshot()
5474    }
5475    fn indexed_file_count(&self) -> Result<usize> {
5476        (**self).indexed_file_count()
5477    }
5478    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5479        (**self).node_for(file_rel, symbol)
5480    }
5481    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5482        (**self).nodes_for(file_rel, symbol)
5483    }
5484    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5485        (**self).nodes_matching(symbol)
5486    }
5487    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5488        (**self).direct_callers_of(file_rel, symbol)
5489    }
5490    fn direct_callers_for_symbols(
5491        &self,
5492        targets: &[(String, String)],
5493    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5494        (**self).direct_callers_for_symbols(targets)
5495    }
5496    fn direct_caller_counts_of(
5497        &self,
5498        targets: &[(String, String)],
5499    ) -> Result<HashMap<(String, String), usize>> {
5500        (**self).direct_caller_counts_of(targets)
5501    }
5502    fn callers_of(
5503        &self,
5504        file_rel: &Path,
5505        symbol: &str,
5506        depth: usize,
5507    ) -> Result<StoreCallersResult> {
5508        (**self).callers_of(file_rel, symbol, depth)
5509    }
5510    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5511        (**self).impact_of(file_rel, symbol, depth)
5512    }
5513    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5514        (**self).outgoing_calls_of(node)
5515    }
5516    fn outgoing_calls_for_symbols(
5517        &self,
5518        sources: &[(String, String)],
5519    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5520        (**self).outgoing_calls_for_symbols(sources)
5521    }
5522    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5523        (**self).resolved_self_calls_of(node)
5524    }
5525    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5526        (**self).unresolved_calls_of(node)
5527    }
5528    fn call_tree(
5529        &self,
5530        file_rel: &Path,
5531        symbol: &str,
5532        depth: usize,
5533    ) -> Result<callgraph::CallTreeNode> {
5534        (**self).call_tree(file_rel, symbol, depth)
5535    }
5536    fn trace_to(
5537        &self,
5538        file_rel: &Path,
5539        symbol: &str,
5540        max_depth: usize,
5541    ) -> Result<callgraph::TraceToResult> {
5542        (**self).trace_to(file_rel, symbol, max_depth)
5543    }
5544    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5545        (**self).trace_to_symbol_candidates(to_symbol)
5546    }
5547    fn trace_to_symbol(
5548        &self,
5549        file_rel: &Path,
5550        symbol: &str,
5551        to_symbol: &str,
5552        to_file: Option<&Path>,
5553        max_depth: usize,
5554    ) -> Result<callgraph::TraceToSymbolResult> {
5555        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5556    }
5557}
5558
5559impl CallGraphRead for ReadonlyCallGraphStore {
5560    fn project_root(&self) -> &Path {
5561        self.project_root()
5562    }
5563    fn project_key(&self) -> &str {
5564        self.project_key()
5565    }
5566    fn sqlite_path(&self) -> &Path {
5567        self.sqlite_path()
5568    }
5569    fn is_current(&self) -> bool {
5570        self.is_current()
5571    }
5572    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5573        self.edge_snapshot()
5574    }
5575    fn indexed_file_count(&self) -> Result<usize> {
5576        self.indexed_file_count()
5577    }
5578    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5579        self.node_for(file_rel, symbol)
5580    }
5581    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5582        self.nodes_for(file_rel, symbol)
5583    }
5584    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5585        self.nodes_matching(symbol)
5586    }
5587    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5588        self.direct_callers_of(file_rel, symbol)
5589    }
5590    fn direct_callers_for_symbols(
5591        &self,
5592        targets: &[(String, String)],
5593    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5594        self.direct_callers_for_symbols(targets)
5595    }
5596    fn direct_caller_counts_of(
5597        &self,
5598        targets: &[(String, String)],
5599    ) -> Result<HashMap<(String, String), usize>> {
5600        self.direct_caller_counts_of(targets)
5601    }
5602    fn callers_of(
5603        &self,
5604        file_rel: &Path,
5605        symbol: &str,
5606        depth: usize,
5607    ) -> Result<StoreCallersResult> {
5608        self.callers_of(file_rel, symbol, depth)
5609    }
5610    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5611        self.impact_of(file_rel, symbol, depth)
5612    }
5613    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5614        self.outgoing_calls_of(node)
5615    }
5616    fn outgoing_calls_for_symbols(
5617        &self,
5618        sources: &[(String, String)],
5619    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5620        self.outgoing_calls_for_symbols(sources)
5621    }
5622    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5623        self.resolved_self_calls_of(node)
5624    }
5625    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5626        self.unresolved_calls_of(node)
5627    }
5628    fn call_tree(
5629        &self,
5630        file_rel: &Path,
5631        symbol: &str,
5632        depth: usize,
5633    ) -> Result<callgraph::CallTreeNode> {
5634        self.call_tree(file_rel, symbol, depth)
5635    }
5636    fn trace_to(
5637        &self,
5638        file_rel: &Path,
5639        symbol: &str,
5640        max_depth: usize,
5641    ) -> Result<callgraph::TraceToResult> {
5642        self.trace_to(file_rel, symbol, max_depth)
5643    }
5644    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5645        self.trace_to_symbol_candidates(to_symbol)
5646    }
5647    fn trace_to_symbol(
5648        &self,
5649        file_rel: &Path,
5650        symbol: &str,
5651        to_symbol: &str,
5652        to_file: Option<&Path>,
5653        max_depth: usize,
5654    ) -> Result<callgraph::TraceToSymbolResult> {
5655        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5656    }
5657}
5658
5659fn indexed_file_count(conn: &Connection) -> Result<usize> {
5660    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5661    Ok(count.max(0) as usize)
5662}
5663
5664fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5665    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5666    match candidates.as_slice() {
5667        [candidate] => Ok(candidate.clone()),
5668        [] => Err(AftError::SymbolNotFound {
5669            name: symbol.to_string(),
5670            file: rel_path.to_string(),
5671        }
5672        .into()),
5673        _ => Err(AftError::AmbiguousSymbol {
5674            name: symbol.to_string(),
5675            candidates: candidates
5676                .iter()
5677                .map(|candidate| candidate.symbol.clone())
5678                .collect(),
5679        }
5680        .into()),
5681    }
5682}
5683
5684fn nodes_for_file_matching_symbol(
5685    conn: &Connection,
5686    rel_path: &str,
5687    symbol: &str,
5688) -> Result<Vec<StoreNode>> {
5689    let qualified_query = symbol.contains("::");
5690    let sql = if qualified_query {
5691        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5692                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5693         FROM nodes n JOIN files f ON f.path = n.file_path
5694         WHERE n.file_path = ?1 AND n.scoped_name = ?2
5695         ORDER BY n.scoped_name, n.start_line, n.start_col"
5696    } else {
5697        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5698                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5699         FROM nodes n JOIN files f ON f.path = n.file_path
5700         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5701         ORDER BY n.scoped_name, n.start_line, n.start_col"
5702    };
5703    let mut stmt = conn.prepare(sql)?;
5704    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5705    rows.collect::<std::result::Result<Vec<_>, _>>()
5706        .map_err(Into::into)
5707}
5708
5709fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5710    let qualified_query = symbol.contains("::");
5711    let sql = if qualified_query {
5712        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5713                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5714         FROM nodes n JOIN files f ON f.path = n.file_path
5715         WHERE n.scoped_name = ?1
5716         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5717    } else {
5718        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5719                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5720         FROM nodes n JOIN files f ON f.path = n.file_path
5721         WHERE n.scoped_name = ?1 OR n.name = ?1
5722         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5723    };
5724    let mut stmt = conn.prepare(sql)?;
5725    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5726    rows.collect::<std::result::Result<Vec<_>, _>>()
5727        .map_err(Into::into)
5728}
5729
5730fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5731    store_node_from_row_at(row, 0)
5732}
5733
5734fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5735    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5736    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5737    let lang_label_value: String = row.get(offset + 10)?;
5738    Ok(StoreNode {
5739        node_id: row.get(offset)?,
5740        file: row.get(offset + 1)?,
5741        symbol: row.get(offset + 2)?,
5742        name: row.get(offset + 3)?,
5743        kind: row.get(offset + 4)?,
5744        line: start_line.saturating_add(1),
5745        end_line: end_line.saturating_add(1),
5746        signature: row.get(offset + 7)?,
5747        exported: row.get::<_, i64>(offset + 8)? != 0,
5748        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5749        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5750    })
5751}
5752
5753fn optional_store_node_from_row_at(
5754    row: &rusqlite::Row<'_>,
5755    offset: usize,
5756) -> rusqlite::Result<Option<StoreNode>> {
5757    if row.get::<_, Option<String>>(offset)?.is_some() {
5758        store_node_from_row_at(row, offset).map(Some)
5759    } else {
5760        Ok(None)
5761    }
5762}
5763
5764#[allow(clippy::too_many_arguments)]
5765fn collect_callers_recursive(
5766    conn: &Connection,
5767    file: &str,
5768    symbol: &str,
5769    max_depth: usize,
5770    current_depth: usize,
5771    visited: &mut HashSet<(String, String)>,
5772    result: &mut Vec<StoreCallSite>,
5773    depth_limited: &mut bool,
5774    truncated: &mut usize,
5775) -> Result<()> {
5776    if current_depth >= max_depth {
5777        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5778        if omitted > 0 {
5779            *depth_limited = true;
5780            *truncated += omitted;
5781        }
5782        return Ok(());
5783    }
5784
5785    if !visited.insert((file.to_string(), symbol.to_string())) {
5786        return Ok(());
5787    }
5788
5789    let sites = direct_callers_for_tuple(conn, file, symbol)?;
5790    for site in sites {
5791        result.push(site.clone());
5792        if current_depth + 1 < max_depth {
5793            collect_callers_recursive(
5794                conn,
5795                &site.caller.file,
5796                &site.caller.symbol,
5797                max_depth,
5798                current_depth + 1,
5799                visited,
5800                result,
5801                depth_limited,
5802                truncated,
5803            )?;
5804        } else {
5805            let omitted =
5806                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5807            if omitted > 0 {
5808                *depth_limited = true;
5809                *truncated += omitted;
5810            }
5811        }
5812    }
5813    Ok(())
5814}
5815
5816// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5817const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5818
5819fn direct_caller_counts_for_tuples(
5820    conn: &Connection,
5821    targets: &[(String, String)],
5822) -> Result<HashMap<(String, String), usize>> {
5823    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5824    let mut counts = unique_targets
5825        .iter()
5826        .cloned()
5827        .map(|target| (target, 0usize))
5828        .collect::<HashMap<_, _>>();
5829
5830    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5831    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5832        let requested_values = (0..chunk.len())
5833            .map(|_| "(?, ?)")
5834            .collect::<Vec<_>>()
5835            .join(", ");
5836        let sql = format!(
5837            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5838             deduped AS (
5839                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5840                 FROM requested requested
5841                 JOIN edges e
5842                   ON e.target_file = requested.target_file
5843                  AND e.target_symbol = requested.target_symbol
5844                  AND e.kind = 'call'
5845                 JOIN refs r ON r.ref_id = e.ref_id
5846                 JOIN nodes src ON src.id = e.source_node
5847                 JOIN files src_file ON src_file.path = src.file_path
5848                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5849             )
5850             SELECT target_file, target_symbol, COUNT(*)
5851             FROM deduped
5852             GROUP BY target_file, target_symbol"
5853        );
5854        let bindings = chunk
5855            .iter()
5856            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5857        let mut stmt = conn.prepare(&sql)?;
5858        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5859            Ok((
5860                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5861                row.get::<_, i64>(2)?,
5862            ))
5863        })?;
5864        for row in rows {
5865            let (target, count) = row?;
5866            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5867        }
5868    }
5869
5870    Ok(counts)
5871}
5872
5873fn direct_caller_count_for_tuple(
5874    conn: &Connection,
5875    target_file: &str,
5876    target_symbol: &str,
5877) -> Result<usize> {
5878    let count: i64 = conn.query_row(
5879        "SELECT COUNT(*)
5880         FROM edges e
5881         JOIN refs r ON r.ref_id = e.ref_id
5882         JOIN nodes src ON src.id = e.source_node
5883         JOIN files src_file ON src_file.path = src.file_path
5884         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5885        params![target_file, target_symbol],
5886        |row| row.get(0),
5887    )?;
5888    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5889}
5890
5891fn direct_callers_for_tuple(
5892    conn: &Connection,
5893    target_file: &str,
5894    target_symbol: &str,
5895) -> Result<Vec<StoreCallSite>> {
5896    let mut stmt = conn.prepare(
5897        "SELECT e.target_file, e.target_symbol, e.line,
5898                r.byte_start, r.byte_end, r.status, e.provenance,
5899                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5900                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5901                src_file.lang,
5902                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5903                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5904                tgt_file.lang
5905         FROM edges e
5906         JOIN refs r ON r.ref_id = e.ref_id
5907         JOIN nodes src ON src.id = e.source_node
5908         JOIN files src_file ON src_file.path = src.file_path
5909         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5910             ON tgt.id = e.target_node
5911         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5912         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5913    )?;
5914    let rows = stmt.query_map(
5915        params![target_file, target_symbol],
5916        direct_call_site_from_row,
5917    )?;
5918    rows.collect::<std::result::Result<Vec<_>, _>>()
5919        .map_err(Into::into)
5920}
5921
5922fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5923    let caller = store_node_from_row_at(row, 7)?;
5924    let target = optional_store_node_from_row_at(row, 18)?;
5925    Ok(StoreCallSite {
5926        caller,
5927        target_file: row.get(0)?,
5928        target_symbol: row.get(1)?,
5929        target,
5930        line: row.get::<_, i64>(2)?.max(0) as u32,
5931        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5932        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5933        resolved: row.get::<_, String>(5)? == "resolved",
5934        provenance: row.get(6)?,
5935    })
5936}
5937
5938fn direct_callers_for_tuples(
5939    conn: &Connection,
5940    targets: &[(String, String)],
5941) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5942    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5943    let mut callers_by_target = unique_targets
5944        .iter()
5945        .cloned()
5946        .map(|target| (target, Vec::new()))
5947        .collect::<HashMap<_, _>>();
5948    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5949
5950    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5951        let requested_values = (0..chunk.len())
5952            .map(|_| "(?, ?)")
5953            .collect::<Vec<_>>()
5954            .join(", ");
5955        let sql = format!(
5956            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5957             SELECT e.target_file, e.target_symbol, e.line,
5958                    r.byte_start, r.byte_end, r.status, e.provenance,
5959                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5960                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5961                    src_file.lang,
5962                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5963                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5964                    tgt_file.lang
5965             FROM requested requested
5966             JOIN edges e
5967               ON e.target_file = requested.target_file
5968              AND e.target_symbol = requested.target_symbol
5969              AND e.kind = 'call'
5970             JOIN refs r ON r.ref_id = e.ref_id
5971             JOIN nodes src ON src.id = e.source_node
5972             JOIN files src_file ON src_file.path = src.file_path
5973             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5974                 ON tgt.id = e.target_node
5975             ORDER BY e.target_file, e.target_symbol, e.source_node,
5976                      r.byte_start, r.line, r.ref_id"
5977        );
5978        let bindings = chunk
5979            .iter()
5980            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5981        let mut stmt = conn.prepare(&sql)?;
5982        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5983            let call = direct_call_site_from_row(row)?;
5984            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5985            Ok((target_key, call))
5986        })?;
5987        for row in rows {
5988            let (target, call) = row?;
5989            callers_by_target
5990                .get_mut(&target)
5991                .expect("batched caller row belongs to a requested target")
5992                .push(call);
5993        }
5994    }
5995
5996    Ok(callers_by_target)
5997}
5998
5999// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
6000const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
6001// Outgoing-edge batches bind one source node per parameter.
6002const OUTGOING_NODE_BATCH_SIZE: usize = 999;
6003
6004fn outgoing_calls_for_symbol_tuples(
6005    conn: &Connection,
6006    sources: &[(String, String)],
6007) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
6008    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
6009    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
6010    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
6011    let source_nodes = unique_sources
6012        .iter()
6013        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
6014        .cloned()
6015        .collect::<Vec<_>>();
6016    let source_nodes_by_id = source_nodes
6017        .iter()
6018        .cloned()
6019        .map(|node| (node.node_id.clone(), node))
6020        .collect::<HashMap<_, _>>();
6021    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
6022
6023    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
6024        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
6025        let sql = format!(
6026            "SELECT e.source_node,
6027                    e.target_file, e.target_symbol, e.line,
6028                    r.byte_start, r.byte_end, r.status, e.provenance,
6029                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
6030                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6031                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6032                    tgt_file.lang
6033             FROM edges e
6034             JOIN refs r ON r.ref_id = e.ref_id
6035             LEFT JOIN nodes tgt ON tgt.id = e.target_node
6036             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
6037             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
6038             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
6039        );
6040        let bindings = chunk.iter().map(|node| node.node_id.as_str());
6041        let mut stmt = conn.prepare(&sql)?;
6042        let rows = stmt.query_map(params_from_iter(bindings), |row| {
6043            let source_node_id = row.get::<_, String>(0)?;
6044            let caller = source_nodes_by_id
6045                .get(&source_node_id)
6046                .expect("batched outgoing row belongs to a requested source node")
6047                .clone();
6048            let target = optional_store_node_from_row_at(row, 8)?;
6049            Ok((
6050                source_node_id,
6051                StoreCallSite {
6052                    caller,
6053                    target_file: row.get(1)?,
6054                    target_symbol: row.get(2)?,
6055                    target,
6056                    line: row.get::<_, i64>(3)?.max(0) as u32,
6057                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
6058                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
6059                    resolved: row.get::<_, String>(6)? == "resolved",
6060                    provenance: row.get(7)?,
6061                },
6062            ))
6063        })?;
6064        for row in rows {
6065            let (source_node_id, call) = row?;
6066            calls_by_node.entry(source_node_id).or_default().push(call);
6067        }
6068    }
6069
6070    let mut calls_by_source = HashMap::new();
6071    for source in &unique_sources {
6072        let mut calls = Vec::new();
6073        if let Some(nodes) = source_nodes_by_symbol.get(source) {
6074            for node in nodes {
6075                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
6076                    calls.extend(node_calls);
6077                }
6078            }
6079        }
6080        calls_by_source.insert(source.clone(), calls);
6081    }
6082
6083    // Resolve each logical target once for the whole frontier. Keeping this separate
6084    // preserves positional-symbol representatives without a correlated lookup per edge.
6085    let target_tuples = calls_by_source
6086        .values()
6087        .flatten()
6088        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
6089        .collect::<Vec<_>>();
6090    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
6091    for calls in calls_by_source.values_mut() {
6092        for call in calls {
6093            if let Some(target) = target_nodes
6094                .get(&(call.target_file.clone(), call.target_symbol.clone()))
6095                .and_then(|nodes| nodes.first())
6096            {
6097                call.target = Some(target.clone());
6098            }
6099        }
6100    }
6101
6102    Ok(calls_by_source)
6103}
6104
6105fn nodes_for_symbol_tuples(
6106    conn: &Connection,
6107    symbols: &[(String, String)],
6108) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
6109    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
6110    let mut nodes_by_symbol = unique_symbols
6111        .iter()
6112        .cloned()
6113        .map(|symbol| (symbol, Vec::new()))
6114        .collect::<HashMap<_, _>>();
6115    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
6116
6117    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
6118        let requested_values = (0..chunk.len())
6119            .map(|_| "(?, ?)")
6120            .collect::<Vec<_>>()
6121            .join(", ");
6122        let sql = format!(
6123            "WITH requested(file, symbol) AS (VALUES {requested_values})
6124             SELECT requested.file, requested.symbol,
6125                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
6126                    node.start_line, node.end_line, node.signature, node.exported,
6127                    node.is_callgraph_entry_point, node_file.lang
6128             FROM requested
6129             JOIN nodes node INDEXED BY idx_nodes_file
6130               ON node.file_path = requested.file
6131              AND node.scoped_name = requested.symbol
6132             JOIN files node_file ON node_file.path = node.file_path
6133             ORDER BY requested.file, requested.symbol,
6134                      node.scoped_name, node.start_line, node.end_line,
6135                      node.start_col, node.range_ordinal"
6136        );
6137        let bindings = chunk
6138            .iter()
6139            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
6140        let mut stmt = conn.prepare(&sql)?;
6141        let rows = stmt.query_map(params_from_iter(bindings), |row| {
6142            Ok((
6143                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
6144                store_node_from_row_at(row, 2)?,
6145            ))
6146        })?;
6147        for row in rows {
6148            let (symbol, node) = row?;
6149            nodes_by_symbol.entry(symbol).or_default().push(node);
6150        }
6151    }
6152
6153    Ok(nodes_by_symbol)
6154}
6155
6156fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6157    let mut stmt = conn.prepare(
6158        "SELECT e.target_file, e.target_symbol, e.line,
6159                r.byte_start, r.byte_end, r.status, e.provenance,
6160                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6161                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6162                tgt_file.lang
6163         FROM edges e
6164         JOIN refs r ON r.ref_id = e.ref_id
6165         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6166             ON tgt.id = e.target_node
6167         WHERE e.kind = 'call' AND e.source_node = ?1
6168         ORDER BY r.byte_start, r.line, r.ref_id",
6169    )?;
6170    let rows = stmt.query_map(params![node.node_id], |row| {
6171        let target = optional_store_node_from_row_at(row, 7)?;
6172        Ok(StoreCallSite {
6173            caller: node.clone(),
6174            target_file: row.get(0)?,
6175            target_symbol: row.get(1)?,
6176            target,
6177            line: row.get::<_, i64>(2)?.max(0) as u32,
6178            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6179            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6180            resolved: row.get::<_, String>(5)? == "resolved",
6181            provenance: row.get(6)?,
6182        })
6183    })?;
6184    rows.collect::<std::result::Result<Vec<_>, _>>()
6185        .map_err(Into::into)
6186}
6187
6188fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6189    let mut stmt = conn.prepare(
6190        "SELECT r.target_file, r.target_symbol, r.line,
6191                r.byte_start, r.byte_end, r.status, r.provenance,
6192                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6193                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6194                tgt_file.lang
6195         FROM refs r
6196         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6197             ON tgt.id = r.target_node
6198         WHERE r.caller_node = ?1
6199           AND r.kind = 'call'
6200           AND r.status <> 'unresolved'
6201           AND r.target_file = ?2
6202           AND r.target_symbol = ?3
6203           AND r.provenance = ?4
6204           AND NOT EXISTS (
6205               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
6206           )
6207         ORDER BY r.byte_start, r.line, r.ref_id",
6208    )?;
6209    let rows = stmt.query_map(
6210        params![
6211            &node.node_id,
6212            &node.file,
6213            &node.symbol,
6214            PROVENANCE_TREESITTER
6215        ],
6216        |row| {
6217            let target = optional_store_node_from_row_at(row, 7)?;
6218            Ok(StoreCallSite {
6219                caller: node.clone(),
6220                target_file: row.get(0)?,
6221                target_symbol: row.get(1)?,
6222                target,
6223                line: row.get::<_, i64>(2)?.max(0) as u32,
6224                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6225                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6226                resolved: row.get::<_, String>(5)? == "resolved",
6227                provenance: row.get(6)?,
6228            })
6229        },
6230    )?;
6231    rows.collect::<std::result::Result<Vec<_>, _>>()
6232        .map_err(Into::into)
6233}
6234
6235fn unresolved_calls_for_node(
6236    conn: &Connection,
6237    node: &StoreNode,
6238) -> Result<Vec<StoreUnresolvedCall>> {
6239    let mut stmt = conn.prepare(
6240        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
6241         FROM refs
6242         WHERE caller_node = ?1
6243           AND kind = 'call'
6244           AND status = 'unresolved'
6245           AND NOT EXISTS (
6246               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6247           )
6248         ORDER BY byte_start, line, ref_id",
6249    )?;
6250    let rows = stmt.query_map(params![node.node_id], |row| {
6251        Ok(StoreUnresolvedCall {
6252            caller: node.clone(),
6253            symbol: row.get(0)?,
6254            full_ref: row.get(1)?,
6255            line: row.get::<_, i64>(2)?.max(0) as u32,
6256            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6257            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6258        })
6259    })?;
6260    rows.collect::<std::result::Result<Vec<_>, _>>()
6261        .map_err(Into::into)
6262}
6263
6264fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
6265    let mut calls = Vec::new();
6266    calls.extend(
6267        outgoing_calls_for_node(conn, node)?
6268            .into_iter()
6269            .map(StoreForwardCall::Resolved),
6270    );
6271    calls.extend(
6272        unresolved_calls_for_node(conn, node)?
6273            .into_iter()
6274            .map(StoreForwardCall::Unresolved),
6275    );
6276    calls.sort_by(|left, right| {
6277        left.byte_start()
6278            .cmp(&right.byte_start())
6279            .then(left.line().cmp(&right.line()))
6280    });
6281    Ok(calls)
6282}
6283
6284fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
6285    let resolved_count: i64 = conn.query_row(
6286        "SELECT COUNT(*)
6287         FROM edges e
6288         JOIN refs r ON r.ref_id = e.ref_id
6289         WHERE e.kind = 'call' AND e.source_node = ?1",
6290        params![&node.node_id],
6291        |row| row.get(0),
6292    )?;
6293    let unresolved_count: i64 = conn.query_row(
6294        "SELECT COUNT(*)
6295         FROM refs
6296         WHERE caller_node = ?1
6297           AND kind = 'call'
6298           AND status = 'unresolved'
6299           AND NOT EXISTS (
6300               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6301           )",
6302        params![&node.node_id],
6303        |row| row.get(0),
6304    )?;
6305    let total = resolved_count.saturating_add(unresolved_count);
6306    Ok(usize::try_from(total).unwrap_or(usize::MAX))
6307}
6308
6309fn call_tree_inner(
6310    conn: &Connection,
6311    node: &StoreNode,
6312    max_depth: usize,
6313    current_depth: usize,
6314    visited: &mut HashSet<(String, String)>,
6315) -> Result<callgraph::CallTreeNode> {
6316    let visit_key = (node.file.clone(), node.symbol.clone());
6317    if visited.contains(&visit_key) {
6318        return Ok(callgraph::CallTreeNode {
6319            name: node.symbol.clone(),
6320            file: node.file.clone(),
6321            line: node.line,
6322            signature: node.signature.clone(),
6323            resolved: true,
6324            children: Vec::new(),
6325            depth_limited: false,
6326            truncated: 0,
6327        });
6328    }
6329    visited.insert(visit_key.clone());
6330
6331    let mut children = Vec::new();
6332    let mut depth_limited = false;
6333    let mut truncated = 0usize;
6334
6335    if current_depth < max_depth {
6336        let calls = forward_calls_for_node(conn, node)?;
6337        for call in calls {
6338            match call {
6339                StoreForwardCall::Resolved(site) => {
6340                    if let Some(target) = site.target {
6341                        let child =
6342                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
6343                        depth_limited |= child.depth_limited;
6344                        truncated += child.truncated;
6345                        children.push(child);
6346                    } else {
6347                        children.push(callgraph::CallTreeNode {
6348                            name: site.target_symbol,
6349                            file: site.target_file,
6350                            line: site.line,
6351                            signature: None,
6352                            resolved: false,
6353                            children: Vec::new(),
6354                            depth_limited: false,
6355                            truncated: 0,
6356                        });
6357                    }
6358                }
6359                StoreForwardCall::Unresolved(call) => {
6360                    children.push(callgraph::CallTreeNode {
6361                        name: call.symbol,
6362                        file: call.caller.file,
6363                        line: call.line,
6364                        signature: None,
6365                        resolved: false,
6366                        children: Vec::new(),
6367                        depth_limited: false,
6368                        truncated: 0,
6369                    });
6370                }
6371            }
6372        }
6373    } else {
6374        truncated = forward_call_count_for_node(conn, node)?;
6375        depth_limited = truncated > 0;
6376    }
6377
6378    visited.remove(&visit_key);
6379    Ok(callgraph::CallTreeNode {
6380        name: node.symbol.clone(),
6381        file: node.file.clone(),
6382        line: node.line,
6383        signature: node.signature.clone(),
6384        resolved: true,
6385        children,
6386        depth_limited,
6387        truncated,
6388    })
6389}
6390
6391fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
6392    callgraph::TraceToSymbolHop {
6393        symbol: node.symbol.clone(),
6394        file: node.file.clone(),
6395        line: node.line,
6396    }
6397}
6398
6399fn trace_to_symbol_matches_target(
6400    node: &StoreNode,
6401    to_symbol: &str,
6402    to_file: Option<&str>,
6403) -> bool {
6404    if !symbol_query_matches(&node.symbol, to_symbol) {
6405        return false;
6406    }
6407    match to_file {
6408        Some(file) => node.file == file,
6409        None => true,
6410    }
6411}
6412
6413fn symbol_query_matches(symbol: &str, query: &str) -> bool {
6414    symbol == query || unqualified_name(symbol) == query
6415}
6416
6417fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
6418    let source = std::fs::read_to_string(path).ok()?;
6419    Some(source.lines().map(|line| line.trim().to_string()).collect())
6420}
6421
6422#[doc(hidden)]
6423pub fn live_callgraph_edge_snapshot(
6424    project_root: &Path,
6425    files: &[PathBuf],
6426) -> Result<BTreeSet<StoredEdge>> {
6427    let files = normalize_file_list(project_root, files)?;
6428    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
6429    let mut file_data = Vec::new();
6430    for file in &files {
6431        let canon = canonicalize_path(file);
6432        let data = graph.build_file(&canon)?.clone();
6433        file_data.push((canon, data));
6434    }
6435
6436    let mut edges = BTreeSet::new();
6437    for (caller_file, data) in &file_data {
6438        for (caller_symbol, call_sites) in &data.calls_by_symbol {
6439            for call_site in call_sites {
6440                let resolution = graph.resolve_cross_file_edge(
6441                    &call_site.full_callee,
6442                    &call_site.callee_name,
6443                    caller_file,
6444                    &data.import_block,
6445                );
6446                let (target_file, target_symbol) = match resolution {
6447                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
6448                    EdgeResolution::Unresolved { callee_name } => {
6449                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
6450                            continue;
6451                        }
6452                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
6453                            data,
6454                            caller_file,
6455                            &callee_name,
6456                        ) else {
6457                            continue;
6458                        };
6459                        (caller_file.clone(), target_symbol)
6460                    }
6461                };
6462                if target_file == *caller_file && target_symbol == *caller_symbol {
6463                    continue;
6464                }
6465                edges.insert(StoredEdge {
6466                    source_file: relative_path(project_root, caller_file),
6467                    source_symbol: caller_symbol.clone(),
6468                    target_file: relative_path(project_root, &target_file),
6469                    target_symbol,
6470                    kind: "call".to_string(),
6471                    line: call_site.line,
6472                });
6473            }
6474        }
6475    }
6476    Ok(edges)
6477}
6478
6479fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
6480{
6481    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
6482}
6483
6484fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
6485    RebuildCooldownKey {
6486        callgraph_dir: std::fs::canonicalize(callgraph_dir)
6487            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
6488        project_key: project_key.to_string(),
6489    }
6490}
6491
6492fn rebuild_cooldown_denial(
6493    callgraph_dir: &Path,
6494    project_key: &str,
6495    project_root: &Path,
6496    now: Instant,
6497) -> Option<(PathBuf, Duration)> {
6498    let key = rebuild_cooldown_key(callgraph_dir, project_key);
6499    let records = rebuild_cooldown_records()
6500        .lock()
6501        .unwrap_or_else(std::sync::PoisonError::into_inner);
6502    let record = records.get(&key)?;
6503    if record.project_root == project_root || !record.cross_root_cooldown_armed {
6504        return None;
6505    }
6506    let elapsed = now.saturating_duration_since(record.published_at);
6507    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
6508}
6509
6510fn record_successful_rebuild(
6511    callgraph_dir: &Path,
6512    project_key: &str,
6513    project_root: &Path,
6514    published_at: Instant,
6515) {
6516    let key = rebuild_cooldown_key(callgraph_dir, project_key);
6517    let mut records = rebuild_cooldown_records()
6518        .lock()
6519        .unwrap_or_else(std::sync::PoisonError::into_inner);
6520    if records.len() >= 4_096 && !records.contains_key(&key) {
6521        if let Some(evict) = records.keys().next().cloned() {
6522            records.remove(&evict);
6523        }
6524    }
6525    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
6526        previous.cross_root_cooldown_armed || previous.project_root != project_root
6527    });
6528    records.insert(
6529        key,
6530        RebuildCooldownRecord {
6531            project_root: project_root.to_path_buf(),
6532            published_at,
6533            cross_root_cooldown_armed,
6534        },
6535    );
6536}
6537
6538fn acquire_writer_lease(
6539    callgraph_dir: &Path,
6540    project_key: &str,
6541    project_root: &Path,
6542) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6543    crate::root_cache::WriterLease::acquire_shared(
6544        crate::root_cache::RootCacheDomain::Callgraph,
6545        callgraph_dir,
6546        project_key,
6547        project_root,
6548    )
6549    .map_err(CallGraphStoreError::from)
6550}
6551
6552fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6553    if lease.verify()? {
6554        Ok(())
6555    } else {
6556        Err(CallGraphStoreError::Unavailable(format!(
6557            "callgraph writer lease for key {} lost epoch {}; aborting write",
6558            lease.key(),
6559            lease.epoch()
6560        )))
6561    }
6562}
6563
6564fn legacy_migration_completion_line(
6565    project_key: &str,
6566    method: &str,
6567    legacy_bytes: u64,
6568    migrated_bytes: u64,
6569) -> String {
6570    format!(
6571        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6572    )
6573}
6574
6575fn log_legacy_migration_completion(
6576    project_key: &str,
6577    method: &str,
6578    legacy_bytes: u64,
6579    migrated_bytes: u64,
6580) {
6581    crate::slog_info!(
6582        "{}",
6583        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6584    );
6585}
6586
6587fn try_legacy_migration_or_fallback(
6588    callgraph_dir: &Path,
6589    project_root: &Path,
6590    project_key: &str,
6591    writer_lease: Arc<crate::root_cache::WriterLease>,
6592) -> Result<Option<CallGraphStore>> {
6593    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6594    if partitions.is_empty() {
6595        return Ok(None);
6596    }
6597
6598    for partition in &partitions {
6599        if let Some(source) = newest_superseded_legacy_generation(partition)? {
6600            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6601                return open_legacy_fallback_store(
6602                    callgraph_dir,
6603                    project_root,
6604                    project_key,
6605                    &partitions,
6606                );
6607            }
6608            match publish_generation_copy_migration(
6609                callgraph_dir,
6610                project_key,
6611                &source,
6612                Arc::clone(&writer_lease),
6613            ) {
6614                Ok(published) => {
6615                    log_legacy_migration_completion(
6616                        project_key,
6617                        "generation_copy",
6618                        source.source_bytes,
6619                        published.migrated_bytes,
6620                    );
6621                    return CallGraphStore::open_generation(
6622                        callgraph_dir,
6623                        project_root.to_path_buf(),
6624                        project_key.to_string(),
6625                        published.generation,
6626                        writer_lease,
6627                    )
6628                    .map(Some);
6629                }
6630                Err(error) => {
6631                    crate::slog_warn!(
6632                        "root-keyed callgraph generation-copy migration failed from {}: {}",
6633                        source.sqlite_path.display(),
6634                        error
6635                    );
6636                    return open_legacy_fallback_store(
6637                        callgraph_dir,
6638                        project_root,
6639                        project_key,
6640                        &partitions,
6641                    );
6642                }
6643            }
6644        }
6645
6646        if let Some(source) = current_legacy_generation(partition)? {
6647            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6648                return open_legacy_fallback_store(
6649                    callgraph_dir,
6650                    project_root,
6651                    project_key,
6652                    &partitions,
6653                );
6654            }
6655            match publish_backup_migration(
6656                callgraph_dir,
6657                project_key,
6658                &source,
6659                Arc::clone(&writer_lease),
6660            ) {
6661                Ok(published) => {
6662                    log_legacy_migration_completion(
6663                        project_key,
6664                        "sqlite_backup",
6665                        source.source_bytes,
6666                        published.migrated_bytes,
6667                    );
6668                    return CallGraphStore::open_generation(
6669                        callgraph_dir,
6670                        project_root.to_path_buf(),
6671                        project_key.to_string(),
6672                        published.generation,
6673                        writer_lease,
6674                    )
6675                    .map(Some);
6676                }
6677                Err(error) => {
6678                    crate::slog_warn!(
6679                        "root-keyed callgraph backup migration failed from {}: {}",
6680                        source.sqlite_path.display(),
6681                        error
6682                    );
6683                    return open_legacy_fallback_store(
6684                        callgraph_dir,
6685                        project_root,
6686                        project_key,
6687                        &partitions,
6688                    );
6689                }
6690            }
6691        }
6692    }
6693
6694    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6695}
6696
6697fn open_legacy_fallback_store(
6698    callgraph_dir: &Path,
6699    project_root: &Path,
6700    project_key: &str,
6701    partitions: &[LegacyCallgraphPartition],
6702) -> Result<Option<CallGraphStore>> {
6703    let Some(target) = first_ready_legacy_target(partitions)? else {
6704        return Ok(None);
6705    };
6706    crate::slog_warn!(
6707        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6708        target.partition.harness,
6709        target.sqlite_path.display()
6710    );
6711    let conn = open_readonly_connection(&target.sqlite_path)?;
6712    if !database_ready(&conn).unwrap_or(false) {
6713        return Ok(None);
6714    }
6715    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6716    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6717    Ok(Some(CallGraphStore::from_connection(
6718        project_root.to_path_buf(),
6719        project_key.to_string(),
6720        target.sqlite_path,
6721        callgraph_dir.to_path_buf(),
6722        true,
6723        target.generation,
6724        None,
6725        Some(read_marker),
6726        conn,
6727    )))
6728}
6729
6730fn migration_disk_floor_allows(
6731    source: &LegacyCallgraphTarget,
6732    callgraph_dir: &Path,
6733) -> Result<bool> {
6734    let available = migration_available_disk(callgraph_dir)?;
6735    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6736        source.source_bytes,
6737        available,
6738    );
6739    if decision.should_skip_copy() {
6740        crate::slog_warn!(
6741            "{}",
6742            decision.warning_message(&source.sqlite_path, callgraph_dir)
6743        );
6744        return Ok(false);
6745    }
6746    Ok(true)
6747}
6748
6749fn migration_available_disk(path: &Path) -> Result<u64> {
6750    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6751        return Ok(bytes);
6752    }
6753    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6754}
6755
6756fn legacy_callgraph_partitions(
6757    callgraph_dir: &Path,
6758    project_key: &str,
6759) -> Result<Vec<LegacyCallgraphPartition>> {
6760    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6761        return Ok(Vec::new());
6762    };
6763    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6764    let mut partitions = inventory
6765        .into_iter()
6766        .filter(|entry| {
6767            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6768                && entry.key == project_key
6769        })
6770        .map(|entry| {
6771            let dir = if entry.path.is_dir() {
6772                entry.path.clone()
6773            } else {
6774                entry
6775                    .path
6776                    .parent()
6777                    .map(Path::to_path_buf)
6778                    .unwrap_or_else(|| entry.path.clone())
6779            };
6780            LegacyCallgraphPartition {
6781                harness: entry.harness,
6782                dir,
6783                key: entry.key,
6784                bytes: entry.bytes,
6785                freshness: entry.callgraph_pointer_mtime,
6786            }
6787        })
6788        .collect::<Vec<_>>();
6789    partitions.sort_by(|left, right| {
6790        right
6791            .freshness
6792            .cmp(&left.freshness)
6793            .then_with(|| right.bytes.cmp(&left.bytes))
6794            .then_with(|| left.harness.cmp(&right.harness))
6795    });
6796    Ok(partitions)
6797}
6798
6799fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6800    let domain_dir = callgraph_dir.parent()?;
6801    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6802        return None;
6803    }
6804    domain_dir.parent().map(Path::to_path_buf)
6805}
6806
6807pub(crate) fn all_legacy_partitions_migrated_for_keys(
6808    callgraph_dir: &Path,
6809    configured_keys: &BTreeSet<String>,
6810) -> Result<bool> {
6811    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6812        return Ok(false);
6813    };
6814    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6815        .into_iter()
6816        .filter(|entry| {
6817            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6818                && configured_keys.contains(&entry.key)
6819        })
6820        .map(|entry| entry.key)
6821        .collect::<BTreeSet<_>>();
6822    if legacy_keys.is_empty() {
6823        return Ok(false);
6824    }
6825
6826    for key in legacy_keys {
6827        let migrated_dir = storage_root.join("callgraph").join(&key);
6828        let Some(generation) = read_pointer(&migrated_dir, &key) else {
6829            return Ok(false);
6830        };
6831        if !migration_generation_requires_manifest(&generation)
6832            || !migration_manifest_valid(&migrated_dir, &generation)
6833        {
6834            return Ok(false);
6835        }
6836    }
6837    Ok(true)
6838}
6839
6840fn newest_superseded_legacy_generation(
6841    partition: &LegacyCallgraphPartition,
6842) -> Result<Option<LegacyCallgraphTarget>> {
6843    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6844        return Ok(None);
6845    };
6846    let prefix = format!("{}.g", partition.key);
6847    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6848        return Ok(None);
6849    };
6850    let mut candidates = Vec::new();
6851    for entry in entries.flatten() {
6852        let name = entry.file_name().to_string_lossy().to_string();
6853        if name == current
6854            || name.contains(".tmp.")
6855            || !name.starts_with(&prefix)
6856            || !name.ends_with(".sqlite")
6857        {
6858            continue;
6859        }
6860        let path = entry.path();
6861        if !db_path_ready(&path) {
6862            continue;
6863        }
6864        let modified = entry
6865            .metadata()
6866            .and_then(|metadata| metadata.modified())
6867            .unwrap_or(SystemTime::UNIX_EPOCH);
6868        candidates.push((modified, path, name));
6869    }
6870    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6871    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6872        return Ok(None);
6873    };
6874    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6875    Ok(Some(LegacyCallgraphTarget {
6876        partition: partition.clone(),
6877        sqlite_path,
6878        generation: Some(generation),
6879        source_bytes,
6880        source_blake3: String::new(),
6881    }))
6882}
6883
6884fn current_legacy_generation(
6885    partition: &LegacyCallgraphPartition,
6886) -> Result<Option<LegacyCallgraphTarget>> {
6887    let Some(target) = ready_legacy_target(partition)? else {
6888        return Ok(None);
6889    };
6890    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6891    if has_superseded {
6892        return Ok(None);
6893    }
6894    Ok(Some(target))
6895}
6896
6897fn freshest_legacy_fallback_target(
6898    callgraph_dir: &Path,
6899    project_key: &str,
6900) -> Result<Option<LegacyCallgraphTarget>> {
6901    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6902    first_ready_legacy_target(&partitions)
6903}
6904
6905fn first_ready_legacy_target(
6906    partitions: &[LegacyCallgraphPartition],
6907) -> Result<Option<LegacyCallgraphTarget>> {
6908    for partition in partitions {
6909        if let Some(target) = ready_legacy_target(partition)? {
6910            return Ok(Some(target));
6911        }
6912    }
6913    Ok(None)
6914}
6915
6916fn ready_legacy_target(
6917    partition: &LegacyCallgraphPartition,
6918) -> Result<Option<LegacyCallgraphTarget>> {
6919    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6920        let sqlite_path = partition.dir.join(&generation);
6921        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6922            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6923            return Ok(Some(LegacyCallgraphTarget {
6924                partition: partition.clone(),
6925                sqlite_path,
6926                generation: Some(generation),
6927                source_bytes,
6928                source_blake3: String::new(),
6929            }));
6930        }
6931    }
6932
6933    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6934    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6935        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6936        return Ok(Some(LegacyCallgraphTarget {
6937            partition: partition.clone(),
6938            sqlite_path,
6939            generation: None,
6940            source_bytes,
6941            source_blake3: String::new(),
6942        }));
6943    }
6944    Ok(None)
6945}
6946
6947fn publish_generation_copy_migration(
6948    callgraph_dir: &Path,
6949    project_key: &str,
6950    source: &LegacyCallgraphTarget,
6951    writer_lease: Arc<crate::root_cache::WriterLease>,
6952) -> Result<PublishedLegacyMigration> {
6953    let generation = migration_generation_file_name(project_key, "copy");
6954    let temp_path = migration_temp_path(callgraph_dir, &generation);
6955    remove_sqlite_file_set(&temp_path);
6956    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6957    fail_after_temp_copy_for_test()?;
6958
6959    let mut source = source.clone();
6960    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6961    source.source_blake3 = fingerprint.blake3;
6962    let generation = publish_migrated_generation(
6963        callgraph_dir,
6964        project_key,
6965        &generation,
6966        &temp_path,
6967        &source,
6968        fingerprint.bytes,
6969        writer_lease,
6970        "generation_copy",
6971    )?;
6972    Ok(PublishedLegacyMigration {
6973        generation,
6974        migrated_bytes: fingerprint.bytes,
6975    })
6976}
6977
6978fn publish_backup_migration(
6979    callgraph_dir: &Path,
6980    project_key: &str,
6981    source: &LegacyCallgraphTarget,
6982    writer_lease: Arc<crate::root_cache::WriterLease>,
6983) -> Result<PublishedLegacyMigration> {
6984    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6985        return Err(CallGraphStoreError::Unavailable(
6986            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6987        ));
6988    }
6989
6990    let generation = migration_generation_file_name(project_key, "backup");
6991    let temp_path = migration_temp_path(callgraph_dir, &generation);
6992    remove_sqlite_file_set(&temp_path);
6993
6994    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6995    let mut destination = TrackedConnection::open(&temp_path, SqliteStore::CallgraphGeneration)?;
6996    destination.busy_timeout(Duration::from_secs(5))?;
6997    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6998    let started = Instant::now();
6999    let mut retries = 0;
7000    loop {
7001        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
7002            rusqlite::backup::StepResult::Done => break,
7003            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
7004            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
7005                retries += 1;
7006                if retries > MIGRATION_BACKUP_RETRY_BUDGET
7007                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
7008                {
7009                    return Err(CallGraphStoreError::Unavailable(format!(
7010                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
7011                    )));
7012                }
7013                std::thread::sleep(Duration::from_millis(20));
7014            }
7015            _ => {
7016                return Err(CallGraphStoreError::Unavailable(
7017                    "legacy callgraph backup returned an unknown step result".to_string(),
7018                ));
7019            }
7020        }
7021    }
7022    drop(backup);
7023
7024    let integrity: String =
7025        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
7026    if integrity != "ok" {
7027        return Err(CallGraphStoreError::Unavailable(format!(
7028            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
7029        )));
7030    }
7031    if !database_ready(&destination)? {
7032        return Err(CallGraphStoreError::Unavailable(
7033            "legacy callgraph backup produced a database without ready metadata".to_string(),
7034        ));
7035    }
7036    destination.execute_batch("PRAGMA optimize;")?;
7037    drop(destination);
7038    sync_file(&temp_path)?;
7039    fail_after_temp_copy_for_test()?;
7040
7041    let mut source = source.clone();
7042    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
7043    source.source_blake3 = fingerprint.blake3;
7044    let generation = publish_migrated_generation(
7045        callgraph_dir,
7046        project_key,
7047        &generation,
7048        &temp_path,
7049        &source,
7050        fingerprint.bytes,
7051        writer_lease,
7052        "sqlite_backup",
7053    )?;
7054    Ok(PublishedLegacyMigration {
7055        generation,
7056        migrated_bytes: fingerprint.bytes,
7057    })
7058}
7059
7060fn publish_migrated_generation(
7061    callgraph_dir: &Path,
7062    project_key: &str,
7063    generation: &str,
7064    temp_path: &Path,
7065    source: &LegacyCallgraphTarget,
7066    migrated_bytes: u64,
7067    writer_lease: Arc<crate::root_cache::WriterLease>,
7068    method: &str,
7069) -> Result<String> {
7070    let gen_path = callgraph_dir.join(generation);
7071    checkpoint_sqlite_before_publication(temp_path);
7072    let publication = publish_if_current(|| {
7073        verify_writer_lease(&writer_lease)?;
7074        remove_sqlite_file_set(&gen_path);
7075        rename_sqlite_file_set(temp_path, &gen_path)?;
7076        crate::fs_lock::sync_parent(&gen_path);
7077
7078        verify_writer_lease(&writer_lease)?;
7079        publish_pointer(callgraph_dir, project_key, generation)?;
7080        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
7081        Ok(generation.to_string())
7082    });
7083    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
7084        remove_sqlite_file_set(temp_path);
7085    }
7086    publication
7087}
7088
7089fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7090    if let Some(parent) = destination.parent() {
7091        std::fs::create_dir_all(parent)?;
7092    }
7093    for suffix in SQLITE_FILE_SET_SUFFIXES {
7094        let source_path = sqlite_file_set_path(source, suffix);
7095        if !source_path.is_file() {
7096            continue;
7097        }
7098        let destination_path = sqlite_file_set_path(destination, suffix);
7099        std::fs::copy(&source_path, &destination_path)?;
7100        sync_file(&destination_path)?;
7101    }
7102    Ok(())
7103}
7104
7105fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7106    for suffix in SQLITE_FILE_SET_SUFFIXES {
7107        let source_path = sqlite_file_set_path(source, suffix);
7108        if !source_path.exists() {
7109            continue;
7110        }
7111        let destination_path = sqlite_file_set_path(destination, suffix);
7112        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
7113            let _ = std::fs::remove_file(&source_path);
7114            return Err(error.into());
7115        }
7116    }
7117    Ok(())
7118}
7119
7120fn sqlite_file_set_size(path: &Path) -> Result<u64> {
7121    let mut bytes = 0_u64;
7122    for suffix in SQLITE_FILE_SET_SUFFIXES {
7123        let member = sqlite_file_set_path(path, suffix);
7124        if !member.is_file() {
7125            continue;
7126        }
7127        bytes = bytes.saturating_add(member.metadata()?.len());
7128    }
7129    Ok(bytes)
7130}
7131
7132fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
7133    let mut hasher = blake3::Hasher::new();
7134    let mut bytes = 0_u64;
7135    let mut buffer = [0_u8; 64 * 1024];
7136    for suffix in SQLITE_FILE_SET_SUFFIXES {
7137        let member = sqlite_file_set_path(path, suffix);
7138        if !member.is_file() {
7139            continue;
7140        }
7141        hasher.update(suffix.as_bytes());
7142        let mut file = std::fs::File::open(&member)?;
7143        loop {
7144            let read = file.read(&mut buffer)?;
7145            if read == 0 {
7146                break;
7147            }
7148            bytes = bytes.saturating_add(read as u64);
7149            hasher.update(&buffer[..read]);
7150        }
7151    }
7152    Ok(SourceFingerprint {
7153        bytes,
7154        blake3: hash_to_hex(hasher.finalize()),
7155    })
7156}
7157
7158fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
7159    if suffix.is_empty() {
7160        path.to_path_buf()
7161    } else {
7162        PathBuf::from(format!("{}{suffix}", path.display()))
7163    }
7164}
7165
7166fn sync_file(path: &Path) -> Result<()> {
7167    let file = std::fs::OpenOptions::new()
7168        .read(true)
7169        .write(true)
7170        .open(path)?;
7171    file.sync_all()?;
7172    Ok(())
7173}
7174
7175fn fail_after_temp_copy_for_test() -> Result<()> {
7176    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
7177        return Err(CallGraphStoreError::Unavailable(
7178            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
7179        ));
7180    }
7181    Ok(())
7182}
7183
7184fn migration_generation_file_name(project_key: &str, method: &str) -> String {
7185    format!(
7186        "{project_key}.g{}.{}{}{}.sqlite",
7187        now_nanos(),
7188        std::process::id(),
7189        MIGRATION_GENERATION_TAG,
7190        method
7191    )
7192}
7193
7194fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7195    callgraph_dir.join(format!(
7196        "{generation}.tmp.{}.{}",
7197        std::process::id(),
7198        now_nanos()
7199    ))
7200}
7201
7202fn write_migration_manifest(
7203    callgraph_dir: &Path,
7204    generation: &str,
7205    source: &LegacyCallgraphTarget,
7206    migrated_bytes: u64,
7207    method: &str,
7208) -> Result<()> {
7209    let manifest_path = migration_manifest_path(callgraph_dir, generation);
7210    let temp_path = manifest_path.with_extension(format!(
7211        "migration.json.tmp.{}.{}",
7212        std::process::id(),
7213        now_nanos()
7214    ));
7215    let manifest = serde_json::json!({
7216        "version": MIGRATION_MANIFEST_VERSION,
7217        "method": method,
7218        "target_generation": generation,
7219        "source_harness": source.partition.harness,
7220        "source_path": source.sqlite_path.display().to_string(),
7221        "source_generation": source.generation,
7222        "source_bytes": source.source_bytes,
7223        "source_blake3": source.source_blake3,
7224        "migrated_bytes": migrated_bytes,
7225    });
7226    {
7227        use std::io::Write as _;
7228        let mut file = std::fs::File::create(&temp_path)?;
7229        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
7230        file.write_all(b"\n")?;
7231        file.sync_all()?;
7232    }
7233    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
7234        let _ = std::fs::remove_file(&temp_path);
7235        return Err(error.into());
7236    }
7237    crate::fs_lock::sync_parent(&manifest_path);
7238    Ok(())
7239}
7240
7241fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7242    callgraph_dir.join(format!("{generation}.migration.json"))
7243}
7244
7245fn migration_generation_requires_manifest(generation: &str) -> bool {
7246    generation.contains(MIGRATION_GENERATION_TAG)
7247}
7248
7249fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
7250    if !migration_generation_requires_manifest(generation) {
7251        return true;
7252    }
7253    let path = migration_manifest_path(callgraph_dir, generation);
7254    let Ok(bytes) = std::fs::read(path) else {
7255        return false;
7256    };
7257    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
7258        return false;
7259    };
7260    value.get("version").and_then(serde_json::Value::as_u64)
7261        == Some(MIGRATION_MANIFEST_VERSION as u64)
7262        && value
7263            .get("target_generation")
7264            .and_then(serde_json::Value::as_str)
7265            == Some(generation)
7266        && value
7267            .get("source_bytes")
7268            .and_then(serde_json::Value::as_u64)
7269            .is_some_and(|bytes| bytes > 0)
7270        && value
7271            .get("source_blake3")
7272            .and_then(serde_json::Value::as_str)
7273            .is_some_and(|hash| hash.len() == 64)
7274}
7275
7276fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
7277    let pointer_generation = read_pointer(callgraph_dir, project_key);
7278    if let Some(generation) = pointer_generation.as_deref() {
7279        if migration_generation_requires_manifest(generation)
7280            && !migration_manifest_valid(callgraph_dir, generation)
7281        {
7282            let path = callgraph_dir.join(generation);
7283            remove_sqlite_file_set(&path);
7284            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
7285            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
7286        }
7287    }
7288
7289    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7290        return;
7291    };
7292    for entry in entries.flatten() {
7293        let name = entry.file_name().to_string_lossy().to_string();
7294        let path = entry.path();
7295        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
7296            let _ = std::fs::remove_file(path);
7297            continue;
7298        }
7299        if name.starts_with(&format!("{project_key}.g"))
7300            && name.ends_with(".sqlite")
7301            && name.contains(MIGRATION_GENERATION_TAG)
7302            && pointer_generation.as_deref() != Some(&name)
7303            && !migration_manifest_valid(callgraph_dir, &name)
7304        {
7305            remove_sqlite_file_set(&path);
7306            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
7307        }
7308    }
7309    crate::fs_lock::sync_parent(callgraph_dir);
7310}
7311
7312fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
7313    let mut hasher = blake3::Hasher::new();
7314    hasher.update(path.to_string_lossy().as_bytes());
7315    if let Some(generation) = generation {
7316        hasher.update(generation.as_bytes());
7317    }
7318    let digest = hash_to_hex(hasher.finalize());
7319    format!("legacy-{}", &digest[..16])
7320}
7321
7322fn open_readonly_connection(path: &Path) -> Result<TrackedConnection> {
7323    let uri = sqlite_readonly_uri(path);
7324    let conn = TrackedConnection::open_with_flags(
7325        &uri,
7326        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
7327        SqliteStore::CallgraphGeneration,
7328    )?;
7329    conn.pragma_update(None, "synchronous", "NORMAL")?;
7330    conn.busy_timeout(reader_busy_timeout())?;
7331    conn.execute_batch("PRAGMA query_only=ON;")?;
7332    Ok(conn)
7333}
7334
7335fn reader_busy_timeout() -> Duration {
7336    let jitter = (now_nanos() % 500) as u64;
7337    Duration::from_millis(250 + jitter)
7338}
7339
7340fn sqlite_readonly_uri(path: &Path) -> String {
7341    let raw = path.to_string_lossy().replace('\\', "/");
7342    let encoded = percent_encode_sqlite_uri_path(&raw);
7343    if raw.starts_with('/') {
7344        format!("file://{encoded}?mode=ro")
7345    } else if raw.as_bytes().get(1) == Some(&b':') {
7346        format!("file:///{encoded}?mode=ro")
7347    } else {
7348        format!("file:{encoded}?mode=ro")
7349    }
7350}
7351
7352fn percent_encode_sqlite_uri_path(path: &str) -> String {
7353    let mut encoded = String::with_capacity(path.len());
7354    for byte in path.bytes() {
7355        match byte {
7356            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
7357                encoded.push(byte as char)
7358            }
7359            _ => encoded.push_str(&format!("%{byte:02X}")),
7360        }
7361    }
7362    encoded
7363}
7364
7365fn configure_connection(conn: &Connection) -> Result<()> {
7366    // Changing journal mode takes a database lock. Install the busy handler
7367    // first so concurrent cold-build and refresh connections wait rather than
7368    // failing immediately, especially under Windows byte-range locking.
7369    conn.busy_timeout(Duration::from_secs(5))?;
7370    conn.pragma_update(None, "journal_mode", "WAL")?;
7371    conn.pragma_update(None, "synchronous", "NORMAL")?;
7372    conn.pragma_update(
7373        None,
7374        "wal_autocheckpoint",
7375        CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES,
7376    )?;
7377    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7378    Ok(())
7379}
7380
7381fn configure_build_connection(conn: &Connection) -> Result<()> {
7382    // The staging database commits independently recoverable batches. WAL keeps
7383    // those commits durable without forcing a rollback journal rewrite per batch.
7384    // Set the busy handler before WAL because selecting the journal mode itself
7385    // can contend with a connection finishing an earlier staged transaction.
7386    conn.busy_timeout(Duration::from_secs(5))?;
7387    conn.pragma_update(None, "journal_mode", "WAL")?;
7388    conn.pragma_update(None, "synchronous", "NORMAL")?;
7389    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7390    Ok(())
7391}
7392
7393/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
7394/// private temporary copy before publishing it; a busy reader is harmless because
7395/// the next publication or cleanup pass can retry without affecting the source.
7396fn checkpoint_sqlite_before_publication(path: &Path) {
7397    let Ok(conn) = crate::db::lifecycle::TrackedConnection::open(
7398        path,
7399        crate::db::lifecycle::SqliteStore::CallgraphGeneration,
7400    ) else {
7401        return;
7402    };
7403    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
7404    let _ = conn.busy_timeout(Duration::from_secs(5));
7405    let _ = checkpoint_wal_truncate(&conn);
7406}
7407
7408fn checkpoint_wal_truncate(conn: &Connection) -> bool {
7409    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
7410        row.get::<_, i64>(0)
7411    }) {
7412        Ok(0) => true,
7413        Ok(_) => false,
7414        Err(rusqlite::Error::SqliteFailure(error, _))
7415            if matches!(
7416                error.code,
7417                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
7418            ) =>
7419        {
7420            false
7421        }
7422        Err(error) => {
7423            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
7424            false
7425        }
7426    }
7427}
7428
7429pub(crate) use crate::views::materialization::materialize_manifest_view_database;
7430
7431pub(crate) fn initialize_schema(conn: &Connection) -> Result<()> {
7432    conn.execute_batch(
7433        "CREATE TABLE IF NOT EXISTS files (
7434            path                TEXT PRIMARY KEY,
7435            content_hash        TEXT NOT NULL,
7436            mtime_ns            INTEGER NOT NULL,
7437            size                INTEGER NOT NULL,
7438            lang                TEXT NOT NULL,
7439            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
7440            is_public_api       INTEGER NOT NULL DEFAULT 0,
7441            surface_fingerprint TEXT NOT NULL,
7442            indexed_at          INTEGER NOT NULL
7443        );
7444
7445        CREATE TABLE IF NOT EXISTS nodes (
7446            id                         TEXT PRIMARY KEY,
7447            file_path                  TEXT NOT NULL,
7448            name                       TEXT NOT NULL,
7449            scoped_name                TEXT NOT NULL,
7450            kind                       TEXT NOT NULL,
7451            start_line                 INTEGER NOT NULL,
7452            start_col                  INTEGER NOT NULL,
7453            end_line                   INTEGER NOT NULL,
7454            end_col                    INTEGER NOT NULL,
7455            range_ordinal              INTEGER NOT NULL,
7456            signature                  TEXT,
7457            exported                   INTEGER NOT NULL,
7458            is_default_export          INTEGER NOT NULL,
7459            is_type_like               INTEGER NOT NULL,
7460            is_callgraph_entry_point   INTEGER NOT NULL,
7461            provenance                 TEXT NOT NULL,
7462            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
7463        );
7464        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7465        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7466        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7467
7468        CREATE TABLE IF NOT EXISTS refs (
7469            ref_id          TEXT PRIMARY KEY,
7470            caller_node     TEXT,
7471            caller_file     TEXT NOT NULL,
7472            kind            TEXT NOT NULL,
7473            short_name      TEXT,
7474            full_ref        TEXT,
7475            module_path     TEXT,
7476            import_kind     TEXT,
7477            local_name      TEXT,
7478            requested_name  TEXT,
7479            namespace_alias TEXT,
7480            wildcard        INTEGER NOT NULL DEFAULT 0,
7481            line            INTEGER NOT NULL,
7482            byte_start      INTEGER NOT NULL,
7483            byte_end        INTEGER NOT NULL,
7484            status          TEXT NOT NULL,
7485            target_node     TEXT,
7486            target_file     TEXT,
7487            target_symbol   TEXT,
7488            provenance      TEXT NOT NULL
7489        );
7490        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7491        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7492        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7493        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7494        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7495
7496        CREATE TABLE IF NOT EXISTS file_dependencies (
7497            file_path   TEXT NOT NULL,
7498            dep_file    TEXT NOT NULL,
7499            PRIMARY KEY(file_path, dep_file)
7500        );
7501        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7502
7503        CREATE TABLE IF NOT EXISTS edges (
7504            edge_id       TEXT PRIMARY KEY,
7505            ref_id        TEXT NOT NULL,
7506            source_node   TEXT NOT NULL,
7507            target_node   TEXT,
7508            target_file   TEXT NOT NULL,
7509            target_symbol TEXT NOT NULL,
7510            kind          TEXT NOT NULL,
7511            line          INTEGER NOT NULL,
7512            provenance    TEXT NOT NULL
7513        );
7514        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7515        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7516        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7517        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7518
7519        CREATE TABLE IF NOT EXISTS dispatch_hints (
7520            id           TEXT PRIMARY KEY,
7521            method_name  TEXT NOT NULL,
7522            caller_node  TEXT NOT NULL,
7523            file         TEXT NOT NULL,
7524            line         INTEGER NOT NULL,
7525            byte_start   INTEGER NOT NULL,
7526            byte_end     INTEGER NOT NULL,
7527            provenance   TEXT NOT NULL
7528        );
7529        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7530        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7531
7532        CREATE TABLE IF NOT EXISTS type_ref_names (
7533            name TEXT PRIMARY KEY
7534        );
7535
7536        CREATE TABLE IF NOT EXISTS backend_file_state (
7537            backend        TEXT NOT NULL,
7538            workspace_root TEXT NOT NULL,
7539            file_path      TEXT NOT NULL,
7540            content_hash   TEXT NOT NULL,
7541            status         TEXT NOT NULL,
7542            updated_at     INTEGER NOT NULL,
7543            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7544        );
7545        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7546
7547        CREATE TABLE IF NOT EXISTS meta (
7548            k TEXT PRIMARY KEY,
7549            v TEXT NOT NULL
7550        );
7551
7552        -- The file walk is staged on disk so extraction can page through a
7553        -- deterministic inventory without retaining every source path in heap.
7554        CREATE TABLE IF NOT EXISTS staging_file_inventory (
7555            path TEXT PRIMARY KEY,
7556            size INTEGER NOT NULL
7557        ) WITHOUT ROWID;
7558
7559        -- Context needed only while a generation is staged. Raw refs live in
7560        -- `refs` with status `staged`; this table preserves the caller symbol
7561        -- needed to avoid inventing self edges during the later resolve pass.
7562        CREATE TABLE IF NOT EXISTS staging_ref_context (
7563            ref_id        TEXT PRIMARY KEY,
7564            caller_symbol TEXT
7565        );",
7566    )?;
7567    insert_meta(conn)?;
7568    Ok(())
7569}
7570
7571fn insert_meta(conn: &Connection) -> Result<()> {
7572    conn.execute(
7573        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7574        params![SCHEMA_VERSION.to_string()],
7575    )?;
7576    conn.execute(
7577        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7578        params![schema_fingerprint()],
7579    )?;
7580    conn.execute(
7581        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7582        [],
7583    )?;
7584    Ok(())
7585}
7586
7587/// Return the durable revision paired atomically with graph mutations. Stores
7588/// created by older binaries lack the revision row, so callers cannot detect
7589/// in-place graph changes and must not cache their snapshots.
7590const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7591
7592fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7593    let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7594        return Ok(());
7595    };
7596    conn.execute(
7597        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7598        params![
7599            PATH_IDENTITY_MISMATCH_META_KEY,
7600            format!(
7601                "callgraph_path_identity_mismatch path={} project_root={}",
7602                path.display(),
7603                project_root.display()
7604            )
7605        ],
7606    )?;
7607    Ok(())
7608}
7609
7610pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7611    conn.query_row(
7612        "SELECT v FROM meta WHERE k = ?1",
7613        [PATH_IDENTITY_MISMATCH_META_KEY],
7614        |row| row.get(0),
7615    )
7616    .optional()
7617    .map_err(Into::into)
7618}
7619
7620fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7621    let revision: Option<String> = conn
7622        .query_row(
7623            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7624            [],
7625            |row| row.get(0),
7626        )
7627        .optional()?;
7628    revision
7629        .map(|revision| {
7630            revision.parse::<u64>().map_err(|error| {
7631                CallGraphStoreError::Unavailable(format!(
7632                    "callgraph projection write revision is invalid: {error}"
7633                ))
7634            })
7635        })
7636        .transpose()
7637}
7638
7639/// Advance the projection revision inside the graph mutation transaction so a
7640/// cached snapshot never survives an in-place refresh.
7641fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7642    tx.execute(
7643        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7644         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7645        [],
7646    )?;
7647    #[cfg(test)]
7648    note_projection_revision_bump_for_test();
7649    Ok(())
7650}
7651
7652pub(crate) fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7653    conn.execute(
7654        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7655        params![if ready { "1" } else { "0" }],
7656    )?;
7657    Ok(())
7658}
7659
7660fn database_ready(conn: &Connection) -> Result<bool> {
7661    let schema_version: Option<String> = conn
7662        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7663            row.get(0)
7664        })
7665        .optional()?;
7666    let fingerprint: Option<String> = conn
7667        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7668            row.get(0)
7669        })
7670        .optional()?;
7671    let ready: Option<String> = conn
7672        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7673        .optional()?;
7674
7675    let expected_schema = SCHEMA_VERSION.to_string();
7676    let expected_fingerprint = schema_fingerprint();
7677    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7678        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7679        && ready.as_deref() == Some("1"))
7680}
7681
7682fn ensure_database_ready(conn: &Connection) -> Result<()> {
7683    if database_ready(conn)? {
7684        Ok(())
7685    } else {
7686        Err(CallGraphStoreError::Unavailable(
7687            "database is missing, stale, or mid-build".to_string(),
7688        ))
7689    }
7690}
7691
7692fn schema_fingerprint() -> String {
7693    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
7694    // edge sources, broader call extraction) even if the table SHAPE is
7695    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
7696    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
7697    let input =
7698        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7699    hash_to_hex(blake3::hash(input.as_bytes()))
7700}
7701
7702fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7703    tx.execute_batch(
7704        "DELETE FROM staging_ref_context;
7705         DELETE FROM edges;
7706         DELETE FROM file_dependencies;
7707         DELETE FROM refs;
7708         DELETE FROM dispatch_hints;
7709         DELETE FROM type_ref_names;
7710         DELETE FROM backend_file_state;
7711         DELETE FROM nodes;
7712         DELETE FROM files;",
7713    )?;
7714    Ok(())
7715}
7716
7717fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7718    conn.query_row(
7719        "SELECT v FROM meta WHERE k = ?1",
7720        params![STAGED_BUILD_PHASE],
7721        |row| row.get(0),
7722    )
7723    .optional()
7724    .map_err(Into::into)
7725}
7726
7727fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7728    let value = staged_string(conn, key)?;
7729    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7730}
7731
7732fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7733    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7734        row.get::<_, String>(0)
7735    })
7736    .optional()
7737    .map_err(Into::into)
7738}
7739
7740fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7741    tx.execute(
7742        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7743        params![STAGED_BUILD_PHASE, phase],
7744    )?;
7745    Ok(())
7746}
7747
7748fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7749    set_staged_string(tx, key, &value.to_string())
7750}
7751
7752fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7753    tx.execute(
7754        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7755        params![key, value],
7756    )?;
7757    Ok(())
7758}
7759
7760/// The extract rows and this counter update share a SQLite transaction. This is
7761/// intentionally not inferred from file/page growth: rollback removes both the
7762/// rows and the claimed credit, while page reuse cannot fabricate credit.
7763fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7764    tx.execute(
7765        "INSERT INTO meta(k, v) VALUES(?1, ?2)
7766         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7767        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7768    )?;
7769    Ok(())
7770}
7771
7772fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7773    let Ok(source) = std::fs::read_to_string(path) else {
7774        return Ok(false);
7775    };
7776    let Ok(freshness) = collect_source_freshness(path, &source) else {
7777        return Ok(false);
7778    };
7779    let rel_path = relative_path(project_root, path);
7780    let staged_hash = conn
7781        .query_row(
7782            "SELECT content_hash FROM files WHERE path = ?1",
7783            params![rel_path],
7784            |row| row.get::<_, String>(0),
7785        )
7786        .optional()?;
7787    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7788}
7789
7790fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7791    tx.execute(
7792        "DELETE FROM staging_ref_context
7793         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7794        params![rel_path],
7795    )?;
7796    delete_file_rows(tx, rel_path)
7797}
7798
7799fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7800    loop {
7801        let removed = {
7802            let mut statement = conn.prepare(
7803                "SELECT path
7804                 FROM files
7805                 WHERE NOT EXISTS (
7806                     SELECT 1 FROM staging_file_inventory inventory
7807                     WHERE inventory.path = files.path
7808                 )
7809                 ORDER BY path
7810                 LIMIT ?1",
7811            )?;
7812            let paths = statement
7813                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7814                    row.get::<_, String>(0)
7815                })?
7816                .collect::<std::result::Result<Vec<_>, _>>()?;
7817            paths
7818        };
7819        if removed.is_empty() {
7820            return Ok(());
7821        }
7822        let tx = conn.transaction()?;
7823        for path in removed {
7824            delete_staged_file_rows(&tx, &path)?;
7825        }
7826        tx.commit()?;
7827    }
7828}
7829
7830struct StagedFileBatch {
7831    paths: Vec<PathBuf>,
7832    last_path: String,
7833}
7834
7835fn load_staged_file_batch(
7836    conn: &Connection,
7837    project_root: &Path,
7838    after_path: &str,
7839    max_files: usize,
7840    max_bytes: u64,
7841) -> Result<Option<StagedFileBatch>> {
7842    let mut statement = conn.prepare(
7843        "SELECT path, size
7844         FROM staging_file_inventory
7845         WHERE path > ?1
7846         ORDER BY path
7847         LIMIT ?2",
7848    )?;
7849    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7850    let mut paths = Vec::with_capacity(max_files.max(1));
7851    let mut last_path = String::new();
7852    let mut batch_bytes = 0u64;
7853    while let Some(row) = rows.next()? {
7854        let rel_path = row.get::<_, String>(0)?;
7855        let size = row.get::<_, i64>(1)?.max(0) as u64;
7856        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7857            break;
7858        }
7859        batch_bytes = batch_bytes.saturating_add(size);
7860        last_path.clone_from(&rel_path);
7861        paths.push(project_root.join(rel_path));
7862    }
7863    if paths.is_empty() {
7864        Ok(None)
7865    } else {
7866        Ok(Some(StagedFileBatch { paths, last_path }))
7867    }
7868}
7869
7870fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7871    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7872    let mut rows = statement.query([])?;
7873    let mut fingerprint = CorpusFingerprint::default();
7874    while let Some(row) = rows.next()? {
7875        let rel_path = row.get::<_, String>(0)?;
7876        fingerprint.add_path(project_root, &project_root.join(rel_path));
7877    }
7878    Ok(fingerprint.finish(project_root))
7879}
7880
7881fn load_staged_ref_window(
7882    conn: &Connection,
7883    after_rowid: u64,
7884    limit: usize,
7885) -> Result<Vec<StagedRef>> {
7886    let mut statement = conn.prepare(
7887        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7888                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7889                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7890                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7891         FROM refs
7892         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7893         WHERE refs.status = 'staged' AND refs.rowid > ?1
7894         ORDER BY refs.rowid
7895         LIMIT ?2",
7896    )?;
7897    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7898        Ok(StagedRef {
7899            rowid: row.get::<_, i64>(0)? as u64,
7900            raw: RawRef {
7901                ref_id: row.get(1)?,
7902                caller_node: row.get(2)?,
7903                caller_file: row.get(3)?,
7904                kind: row.get(4)?,
7905                short_name: row.get(5)?,
7906                full_ref: row.get(6)?,
7907                module_path: row.get(7)?,
7908                import_kind: row.get(8)?,
7909                local_name: row.get(9)?,
7910                requested_name: row.get(10)?,
7911                namespace_alias: row.get(11)?,
7912                wildcard: row.get::<_, i64>(12)? != 0,
7913                line: row.get::<_, i64>(13)? as u32,
7914                byte_start: row.get::<_, i64>(14)? as usize,
7915                byte_end: row.get::<_, i64>(15)? as usize,
7916                caller_symbol: row.get(16)?,
7917                dependencies: BTreeSet::new(),
7918            },
7919        })
7920    })?;
7921    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7922    drop(statement);
7923
7924    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7925    let mut dependency_statement = conn
7926        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7927    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7928        if !dependencies.contains_key(&raw.caller_file) {
7929            let rows =
7930                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7931            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7932            dependencies.insert(raw.caller_file.clone(), values);
7933        }
7934        raw.dependencies = dependencies
7935            .get(&raw.caller_file)
7936            .cloned()
7937            .unwrap_or_default();
7938    }
7939    Ok(refs)
7940}
7941
7942fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7943    ResolvedRef {
7944        dependencies: raw.dependencies.clone(),
7945        raw,
7946        status: "unresolved".to_string(),
7947        target_node: None,
7948        target_file: None,
7949        target_symbol: None,
7950        edge: None,
7951    }
7952}
7953
7954fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7955    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7956        .map(|count| count.max(0) as u64)
7957        .map_err(Into::into)
7958}
7959
7960fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7961    let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7962    let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7963    let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7964    let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7965    let failed_files = staged_failed_files(conn)?;
7966    let elapsed_ms = started.elapsed().as_millis();
7967    crate::slog_info!(
7968        "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7969        files,
7970        nodes,
7971        refs,
7972        edges,
7973        staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7974        elapsed_ms
7975    );
7976    Ok(ColdBuildStats {
7977        files,
7978        nodes,
7979        refs,
7980        edges,
7981        failed_files,
7982        elapsed_ms,
7983    })
7984}
7985
7986fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7987    let mut statement = conn.prepare(
7988        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7989    )?;
7990    let rows = statement.query_map([], |row| row.get(0))?;
7991    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7992}
7993
7994fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7995    tx.execute_batch(
7996        "DROP INDEX IF EXISTS idx_nodes_file;
7997         DROP INDEX IF EXISTS idx_nodes_name;
7998         DROP INDEX IF EXISTS idx_nodes_scoped;
7999         DROP INDEX IF EXISTS idx_refs_short_name;
8000         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
8001         DROP INDEX IF EXISTS idx_refs_caller_file;
8002         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
8003         DROP INDEX IF EXISTS idx_refs_target_file;
8004         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
8005         DROP INDEX IF EXISTS idx_edges_source_kind;
8006         DROP INDEX IF EXISTS idx_edges_target_kind;
8007         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
8008         DROP INDEX IF EXISTS idx_edges_ref_id;
8009         DROP INDEX IF EXISTS idx_dispatch_hints_method;
8010         DROP INDEX IF EXISTS idx_dispatch_hints_file;
8011         DROP INDEX IF EXISTS idx_backend_file_state_file;",
8012    )?;
8013    Ok(())
8014}
8015
8016fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
8017    tx.execute_batch(
8018        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
8019         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
8020         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
8021         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
8022         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
8023         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
8024         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
8025         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
8026         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
8027         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
8028         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
8029         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
8030         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
8031         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
8032         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
8033         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
8034    )?;
8035    Ok(())
8036}
8037
8038const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
8039    ("files", "path"),
8040    ("nodes", "file_path"),
8041    ("refs", "caller_file"),
8042    ("refs", "target_file"),
8043    ("file_dependencies", "file_path"),
8044    ("file_dependencies", "dep_file"),
8045    ("edges", "target_file"),
8046    ("dispatch_hints", "file"),
8047    ("backend_file_state", "file_path"),
8048];
8049
8050/// Reconcile `backend_file_state.workspace_root` when the opener's project root
8051/// differs from what is stored. The store key is the git-root commit hash, so
8052/// multiple live checkouts/clones share one on-disk generation.
8053///
8054/// Cheap in-place re-root is only safe when every previously stored root path is
8055/// gone from disk (true move/rename). If any stale root still exists, another
8056/// clone is still alive and rewriting metadata would ping-pong relative rows
8057/// between trees (possibly on different branches). We then return
8058/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
8059/// opener. That can make each clone rebuild on open when they alternate — bounded
8060/// by open frequency — but each rebuild is correct for its opener, unlike silent
8061/// cross-clone corruption.
8062fn reconcile_workspace_roots(
8063    conn: &mut Connection,
8064    project_root: &Path,
8065    allow_repair: bool,
8066) -> Result<OpenRootRepair> {
8067    let roots = stored_workspace_roots(conn)?;
8068    let current_root = project_root.display().to_string();
8069    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
8070        return Ok(OpenRootRepair::None);
8071    }
8072
8073    if let Some(sample) = sample_absolute_data_path(conn)? {
8074        return Ok(OpenRootRepair::NeedsRebuild {
8075            previous_roots: roots,
8076            current_root,
8077            reason: format!("absolute store data path row {sample}"),
8078        });
8079    }
8080
8081    for stored_root in roots.iter() {
8082        if stored_root == &current_root {
8083            continue;
8084        }
8085        if Path::new(stored_root).exists() {
8086            let reason = format!(
8087                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
8088            );
8089            return Ok(OpenRootRepair::NeedsRebuild {
8090                previous_roots: roots,
8091                current_root,
8092                reason,
8093            });
8094        }
8095    }
8096
8097    if !allow_repair {
8098        return Ok(OpenRootRepair::NeedsRebuild {
8099            previous_roots: roots,
8100            current_root,
8101            reason: "workspace root metadata requires deferred repair".to_string(),
8102        });
8103    }
8104
8105    publish_if_current(|| {
8106        let tx = conn.transaction()?;
8107        tx.execute(
8108            "UPDATE OR IGNORE backend_file_state
8109             SET workspace_root = ?1
8110             WHERE workspace_root <> ?1",
8111            params![&current_root],
8112        )?;
8113        tx.execute(
8114            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
8115            params![&current_root],
8116        )?;
8117        tx.commit()?;
8118        Ok(())
8119    })?;
8120
8121    crate::slog_info!(
8122        "callgraph store re-rooted from {} to {}",
8123        roots.join(", "),
8124        current_root
8125    );
8126    Ok(OpenRootRepair::ReRooted)
8127}
8128
8129fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
8130    let mut stmt = conn.prepare(
8131        "SELECT DISTINCT workspace_root
8132         FROM backend_file_state
8133         ORDER BY workspace_root",
8134    )?;
8135    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
8136    rows.collect::<std::result::Result<Vec<_>, _>>()
8137        .map_err(Into::into)
8138}
8139
8140fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
8141    for (table, column) in STORE_DATA_PATH_COLUMNS {
8142        let sql = format!(
8143            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
8144        );
8145        let mut stmt = conn.prepare(&sql)?;
8146        let mut rows = stmt.query([])?;
8147        while let Some(row) = rows.next()? {
8148            let value: String = row.get(0)?;
8149            if stored_path_is_absolute(&value) {
8150                return Ok(Some(format!("{table}.{column}={value}")));
8151            }
8152        }
8153    }
8154    Ok(None)
8155}
8156
8157fn stored_path_is_absolute(value: &str) -> bool {
8158    if value.is_empty() {
8159        return false;
8160    }
8161    if Path::new(value).is_absolute() || value.starts_with('/') {
8162        return true;
8163    }
8164    let bytes = value.as_bytes();
8165    if bytes.len() >= 3
8166        && bytes[1] == b':'
8167        && (bytes[2] == b'/' || bytes[2] == b'\\')
8168        && bytes[0].is_ascii_alphabetic()
8169    {
8170        return true;
8171    }
8172    value.starts_with("\\\\") || value.starts_with("//")
8173}
8174
8175fn log_root_repair_rebuild(repair: &OpenRootRepair) {
8176    if let OpenRootRepair::NeedsRebuild {
8177        previous_roots,
8178        current_root,
8179        reason,
8180    } = repair
8181    {
8182        crate::slog_info!(
8183            "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
8184            previous_roots.join(", "),
8185            current_root,
8186            reason
8187        );
8188    }
8189}
8190
8191/// Nanosecond clock used to make temp/generation file names unique.
8192fn now_nanos() -> u128 {
8193    SystemTime::now()
8194        .duration_since(UNIX_EPOCH)
8195        .unwrap_or(Duration::ZERO)
8196        .as_nanos()
8197}
8198
8199/// The pointer file `<dir>/<key>.current`. Its single line names the current
8200/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
8201/// can always be atomically replaced via rename even on Windows — Rust opens
8202/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
8203fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8204    callgraph_dir.join(format!("{project_key}.current"))
8205}
8206
8207/// The legacy single-file DB path used before the generation scheme. Still read
8208/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
8209/// build publishes a generation.
8210fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8211    callgraph_dir.join(format!("{project_key}.sqlite"))
8212}
8213
8214/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
8215/// cold build writes a brand-new generation file, so publishing NEVER replaces
8216/// a file another process holds open (the root Windows fix).
8217fn generation_file_name(project_key: &str) -> String {
8218    format!(
8219        "{project_key}.g{}.{}.sqlite",
8220        now_nanos(),
8221        std::process::id()
8222    )
8223}
8224
8225/// Read the pointer; returns the generation file name if present and non-empty.
8226fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
8227    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
8228    let name = text.trim();
8229    if name.is_empty() {
8230        None
8231    } else {
8232        Some(name.to_string())
8233    }
8234}
8235
8236/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
8237/// `ready` flag). Uses a throwaway read-only connection.
8238fn db_path_ready(path: &Path) -> bool {
8239    (|| -> Result<bool> {
8240        let conn = open_readonly_connection(path)?;
8241        database_ready(&conn)
8242    })()
8243    .unwrap_or(false)
8244}
8245
8246/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
8247/// where `generation` is `Some(name)` for a pointer-published generation or
8248/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
8249/// published (caller treats that as "needs cold build").
8250///
8251/// Handles the GC race (the pointer names a generation that was just deleted) by
8252/// re-reading the pointer and retrying a few times.
8253fn resolve_ready_target(
8254    callgraph_dir: &Path,
8255    project_key: &str,
8256) -> Option<(PathBuf, Option<String>)> {
8257    for _ in 0..5 {
8258        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
8259            let gen_path = callgraph_dir.join(&generation);
8260            if gen_path.is_file() {
8261                return (migration_manifest_valid(callgraph_dir, &generation)
8262                    && db_path_ready(&gen_path))
8263                .then_some((gen_path, Some(generation)));
8264            }
8265            // Pointer names a missing generation (a GC/publish race): re-read the
8266            // pointer and retry rather than failing the reader.
8267            std::thread::sleep(Duration::from_millis(5));
8268            continue;
8269        }
8270        // No pointer: fall back to the legacy single-file DB if it is ready.
8271        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
8272        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
8273    }
8274    None
8275}
8276
8277/// Atomically publish `generation` as the current store by flipping the pointer
8278/// file. Writes a temp file, fsyncs, then renames over the pointer — never
8279/// replacing an open DB file, so it succeeds cross-platform.
8280fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
8281    let pointer = pointer_path(callgraph_dir, project_key);
8282    let tmp = callgraph_dir.join(format!(
8283        "{project_key}.current.tmp.{}.{}",
8284        std::process::id(),
8285        now_nanos()
8286    ));
8287    {
8288        use std::io::Write as _;
8289        let mut file = std::fs::File::create(&tmp)?;
8290        file.write_all(generation.as_bytes())?;
8291        file.write_all(b"\n")?;
8292        file.sync_all()?;
8293    }
8294    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
8295        let _ = std::fs::remove_file(&tmp);
8296        return Err(error.into());
8297    }
8298    crate::fs_lock::sync_parent(&pointer);
8299    Ok(())
8300}
8301
8302#[derive(Clone, Debug)]
8303struct GenerationGcCandidate {
8304    name: String,
8305    path: PathBuf,
8306    modified: SystemTime,
8307}
8308
8309/// Best-effort GC of superseded generation files. The current pointer target and
8310/// newest previous generation are always retained. Older generations are removed
8311/// when they have no protected read marker, or after the absolute retention TTL
8312/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
8313/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
8314fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
8315    let temp_grace = Duration::from_secs(60);
8316    let now = SystemTime::now();
8317    let pointer_current =
8318        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
8319    let gen_prefix = format!("{project_key}.g");
8320    let tmp_prefixes = [
8321        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
8322        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
8323        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
8324    ];
8325    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8326        return;
8327    };
8328    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
8329    for entry in entries.flatten() {
8330        let name = entry.file_name();
8331        let name = name.to_string_lossy().to_string();
8332        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
8333        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
8334
8335        // Orphaned temp files from a crashed build/publish: remove once aged out.
8336        if name.contains(".tmp.") {
8337            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
8338                let _ = std::fs::remove_file(entry.path());
8339            }
8340            continue;
8341        }
8342
8343        // Superseded legacy single-file DB: best-effort delete once a generation
8344        // is published (ignored if another process still holds it open).
8345        if name == format!("{project_key}.sqlite") {
8346            remove_sqlite_file_set(&entry.path());
8347            continue;
8348        }
8349
8350        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
8351            gens.push(GenerationGcCandidate {
8352                name,
8353                path: entry.path(),
8354                modified: mtime,
8355            });
8356        }
8357    }
8358
8359    let mut superseded = gens
8360        .iter()
8361        .filter(|generation| generation.name != pointer_current)
8362        .collect::<Vec<_>>();
8363    superseded.sort_by(|left, right| {
8364        right
8365            .modified
8366            .cmp(&left.modified)
8367            .then_with(|| right.name.cmp(&left.name))
8368    });
8369    let previous = superseded.first().map(|generation| generation.name.clone());
8370
8371    for generation in gens {
8372        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
8373        if generation.name == pointer_current
8374            || Some(generation.name.as_str()) == previous.as_deref()
8375        {
8376            continue;
8377        }
8378
8379        let age = now
8380            .duration_since(generation.modified)
8381            .unwrap_or(Duration::ZERO);
8382        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
8383            continue;
8384        }
8385
8386        remove_sqlite_file_set(&generation.path);
8387        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
8388        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
8389            callgraph_dir,
8390            &generation.name,
8391        ));
8392    }
8393}
8394
8395fn remove_sqlite_file_set(path: &Path) {
8396    let _ = std::fs::remove_file(path);
8397    remove_sqlite_sidecars(path);
8398}
8399
8400fn remove_sqlite_sidecars(path: &Path) {
8401    let path_text = path.to_string_lossy();
8402    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
8403    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
8404    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
8405}
8406
8407#[derive(Clone, Copy, Debug, Default)]
8408struct CallgraphRootSweepSummary {
8409    scanned: usize,
8410    removed: usize,
8411    bytes: u64,
8412    generation_gc: usize,
8413    skipped_memo: usize,
8414    skipped_derived: usize,
8415    skipped_fresh: usize,
8416    skipped_reader: usize,
8417    skipped_lease: usize,
8418    skipped_unreadable: usize,
8419    budget_exhausted: bool,
8420}
8421
8422#[derive(Clone, Copy, Debug, Default)]
8423struct CallgraphRootFileStats {
8424    newest: Option<SystemTime>,
8425    bytes: u64,
8426}
8427
8428enum CallgraphRootWalk {
8429    Complete(CallgraphRootFileStats),
8430    BudgetExceeded,
8431    Failed,
8432}
8433
8434enum CallgraphRootCandidate {
8435    Removed { bytes: u64 },
8436    GenerationGc,
8437    SkippedMemo,
8438    SkippedDerived,
8439    SkippedFresh,
8440    SkippedReader,
8441    SkippedLease,
8442    SkippedUnreadable,
8443    BudgetExceeded,
8444}
8445
8446/// Sweep root-keyed callgraph directories that were detached when cache-key
8447/// eviction forgot a checkout. Generation GC alone only runs while a root
8448/// publishes, so inactive roots otherwise keep every obsolete generation forever.
8449///
8450/// The pass reuses the index-cache liveness boundary and takes each directory's
8451/// writer lease before mutating it. A current memo entry remains eligible only for
8452/// superseded-generation GC; an absent entry is eligible for whole-directory
8453/// deletion after the conservative age threshold.
8454fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
8455    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8456        return;
8457    };
8458    let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
8459    let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
8460        Ok(keys) => keys,
8461        Err(error) => {
8462            crate::slog_warn!(
8463                "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={}",
8464                root_dir.display(),
8465                error
8466            );
8467            return;
8468        }
8469    };
8470    let derived_keys = crate::search_index::derived_artifact_cache_keys();
8471    let summary = sweep_callgraph_root_dirs_with_limits(
8472        &root_dir,
8473        &memo_keys,
8474        &derived_keys,
8475        CALLGRAPH_ROOT_SWEEP_BUDGET,
8476        CALLGRAPH_ROOT_SWEEP_LIMIT,
8477    );
8478    crate::slog_info!(
8479        "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
8480        root_dir.display(),
8481        summary.scanned,
8482        summary.removed,
8483        summary.bytes,
8484        summary.generation_gc,
8485        summary.skipped_memo,
8486        summary.skipped_derived,
8487        summary.skipped_fresh,
8488        summary.skipped_reader,
8489        summary.skipped_lease,
8490        summary.skipped_unreadable,
8491        summary.budget_exhausted
8492    );
8493}
8494
8495fn sweep_callgraph_root_dirs_with_limits(
8496    root_dir: &Path,
8497    memo_keys: &HashSet<String>,
8498    derived_keys: &HashSet<String>,
8499    wall_clock_budget: Duration,
8500    entry_limit: usize,
8501) -> CallgraphRootSweepSummary {
8502    let started = Instant::now();
8503    let deadline = started + wall_clock_budget;
8504    let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
8505        Ok(boundary) => boundary,
8506        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8507            return CallgraphRootSweepSummary::default();
8508        }
8509        Err(error) => {
8510            crate::slog_warn!(
8511                "cannot establish filesystem boundary for callgraph root sweep {}: {}",
8512                root_dir.display(),
8513                error
8514            );
8515            return CallgraphRootSweepSummary {
8516                skipped_unreadable: 1,
8517                ..CallgraphRootSweepSummary::default()
8518            };
8519        }
8520    };
8521    let mut entries = match std::fs::read_dir(root_dir) {
8522        Ok(entries) => entries
8523            .filter_map(|entry| entry.ok())
8524            .filter_map(|entry| {
8525                let name = entry.file_name().to_string_lossy().into_owned();
8526                entry
8527                    .file_type()
8528                    .ok()
8529                    .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8530                    .map(|_| (name, entry.path()))
8531            })
8532            .collect::<Vec<_>>(),
8533        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8534        Err(error) => {
8535            crate::slog_warn!(
8536                "cannot read callgraph root sweep directory {}: {}",
8537                root_dir.display(),
8538                error
8539            );
8540            return CallgraphRootSweepSummary {
8541                skipped_unreadable: 1,
8542                ..CallgraphRootSweepSummary::default()
8543            };
8544        }
8545    };
8546    entries.sort_by(|left, right| left.0.cmp(&right.0));
8547
8548    let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8549    let last_name = cursor_store
8550        .lock()
8551        .ok()
8552        .and_then(|cursors| cursors.get(root_dir).cloned());
8553    if let Some(start) = last_name
8554        .as_deref()
8555        .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8556    {
8557        entries.rotate_left(start);
8558    }
8559
8560    let mut summary = CallgraphRootSweepSummary::default();
8561    let mut cursor_name = last_name;
8562    for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8563        if processed >= entry_limit || Instant::now() >= deadline {
8564            summary.budget_exhausted = true;
8565            break;
8566        }
8567        summary.scanned += 1;
8568        cursor_name = Some(key.clone());
8569        match callgraph_root_candidate(
8570            &cache_dir,
8571            &key,
8572            memo_keys.contains(&key),
8573            derived_keys.contains(&key),
8574            &boundary,
8575            deadline,
8576        ) {
8577            CallgraphRootCandidate::Removed { bytes } => {
8578                summary.removed += 1;
8579                summary.bytes = summary.bytes.saturating_add(bytes);
8580            }
8581            CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8582            CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8583            CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8584            CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8585            CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8586            CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8587            CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8588            CallgraphRootCandidate::BudgetExceeded => {
8589                summary.budget_exhausted = true;
8590                break;
8591            }
8592        }
8593    }
8594
8595    if let Ok(mut cursors) = cursor_store.lock() {
8596        if summary.budget_exhausted {
8597            if let Some(cursor_name) = cursor_name {
8598                cursors.insert(root_dir.to_path_buf(), cursor_name);
8599            }
8600        } else {
8601            cursors.remove(root_dir);
8602        }
8603    }
8604    if summary.removed > 0 {
8605        crate::fs_lock::sync_parent(root_dir);
8606    }
8607    summary
8608}
8609
8610fn callgraph_root_candidate(
8611    cache_dir: &Path,
8612    project_key: &str,
8613    memo_referenced: bool,
8614    derived_in_process: bool,
8615    boundary: &crate::walk_boundary::DeviceBoundary,
8616    deadline: Instant,
8617) -> CallgraphRootCandidate {
8618    if !boundary.should_descend(cache_dir).unwrap_or(false) {
8619        return CallgraphRootCandidate::SkippedUnreadable;
8620    }
8621    if memo_referenced || derived_in_process {
8622        return sweep_live_callgraph_root_generations(
8623            cache_dir,
8624            project_key,
8625            memo_referenced,
8626            boundary,
8627            deadline,
8628        );
8629    }
8630
8631    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8632        CallgraphRootWalk::Complete(stats) => stats,
8633        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8634        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8635    };
8636    let Some(newest) = stats.newest else {
8637        return CallgraphRootCandidate::SkippedUnreadable;
8638    };
8639    if SystemTime::now()
8640        .duration_since(newest)
8641        .unwrap_or(Duration::ZERO)
8642        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8643    {
8644        return CallgraphRootCandidate::SkippedFresh;
8645    }
8646
8647    // Keep the writer lease held through deletion. A concurrent publisher either
8648    // owns it first (and this pass skips) or starts after this directory is gone.
8649    let _writer_lease = match crate::fs_lock::try_acquire(
8650        &crate::root_cache::writer_lease_path(cache_dir),
8651        Duration::ZERO,
8652    ) {
8653        Ok(lease) => lease,
8654        Err(_) => return CallgraphRootCandidate::SkippedLease,
8655    };
8656    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8657        return CallgraphRootCandidate::SkippedReader;
8658    }
8659
8660    match std::fs::remove_dir_all(cache_dir) {
8661        Ok(()) => {
8662            crate::slog_info!(
8663                "callgraph root sweep reaped dir={} key={} bytes={}",
8664                cache_dir.display(),
8665                project_key,
8666                stats.bytes
8667            );
8668            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8669        }
8670        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8671            crate::slog_info!(
8672                "callgraph root sweep reaped dir={} key={} bytes={}",
8673                cache_dir.display(),
8674                project_key,
8675                stats.bytes
8676            );
8677            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8678        }
8679        Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8680    }
8681}
8682
8683fn sweep_live_callgraph_root_generations(
8684    cache_dir: &Path,
8685    project_key: &str,
8686    memo_referenced: bool,
8687    boundary: &crate::walk_boundary::DeviceBoundary,
8688    deadline: Instant,
8689) -> CallgraphRootCandidate {
8690    if Instant::now() >= deadline {
8691        return CallgraphRootCandidate::BudgetExceeded;
8692    }
8693    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8694        CallgraphRootWalk::Complete(stats) => stats,
8695        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8696        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8697    };
8698    let Some(newest) = stats.newest else {
8699        return CallgraphRootCandidate::SkippedUnreadable;
8700    };
8701    if SystemTime::now()
8702        .duration_since(newest)
8703        .unwrap_or(Duration::ZERO)
8704        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8705    {
8706        return CallgraphRootCandidate::SkippedFresh;
8707    }
8708    let _writer_lease = match crate::fs_lock::try_acquire(
8709        &crate::root_cache::writer_lease_path(cache_dir),
8710        Duration::ZERO,
8711    ) {
8712        Ok(lease) => lease,
8713        Err(_) => return CallgraphRootCandidate::SkippedLease,
8714    };
8715    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8716        return CallgraphRootCandidate::SkippedReader;
8717    }
8718    if let Some(current) = read_pointer(cache_dir, project_key) {
8719        gc_old_generations(cache_dir, project_key, &current);
8720        return CallgraphRootCandidate::GenerationGc;
8721    }
8722    if memo_referenced {
8723        CallgraphRootCandidate::SkippedMemo
8724    } else {
8725        CallgraphRootCandidate::SkippedDerived
8726    }
8727}
8728
8729fn callgraph_root_file_stats(
8730    cache_dir: &Path,
8731    boundary: &crate::walk_boundary::DeviceBoundary,
8732    deadline: Instant,
8733) -> CallgraphRootWalk {
8734    let metadata = match std::fs::metadata(cache_dir) {
8735        Ok(metadata) => metadata,
8736        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8737            return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8738        }
8739        Err(_) => return CallgraphRootWalk::Failed,
8740    };
8741    let mut stats = CallgraphRootFileStats {
8742        newest: metadata.modified().ok(),
8743        bytes: 0,
8744    };
8745    match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8746        Ok(()) => CallgraphRootWalk::Complete(stats),
8747        Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8748        Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8749    }
8750}
8751
8752enum CallgraphRootWalkError {
8753    BudgetExceeded,
8754    Failed,
8755}
8756
8757fn callgraph_root_file_stats_inner(
8758    directory: &Path,
8759    boundary: &crate::walk_boundary::DeviceBoundary,
8760    deadline: Instant,
8761    stats: &mut CallgraphRootFileStats,
8762) -> std::result::Result<(), CallgraphRootWalkError> {
8763    if Instant::now() >= deadline {
8764        return Err(CallgraphRootWalkError::BudgetExceeded);
8765    }
8766    let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8767    for entry in entries {
8768        if Instant::now() >= deadline {
8769            return Err(CallgraphRootWalkError::BudgetExceeded);
8770        }
8771        let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8772        let file_type = entry
8773            .file_type()
8774            .map_err(|_| CallgraphRootWalkError::Failed)?;
8775        if file_type.is_symlink() {
8776            return Err(CallgraphRootWalkError::Failed);
8777        }
8778        let path = entry.path();
8779        if file_type.is_dir() {
8780            if !boundary
8781                .should_descend(&path)
8782                .map_err(|_| CallgraphRootWalkError::Failed)?
8783            {
8784                return Err(CallgraphRootWalkError::Failed);
8785            }
8786            let metadata = entry
8787                .metadata()
8788                .map_err(|_| CallgraphRootWalkError::Failed)?;
8789            merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8790            callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8791            continue;
8792        }
8793        if !file_type.is_file() {
8794            return Err(CallgraphRootWalkError::Failed);
8795        }
8796        let metadata = entry
8797            .metadata()
8798            .map_err(|_| CallgraphRootWalkError::Failed)?;
8799        stats.bytes = stats.bytes.saturating_add(metadata.len());
8800        merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8801    }
8802    Ok(())
8803}
8804
8805fn merge_newest_callgraph_root_mtime(
8806    stats: &mut CallgraphRootFileStats,
8807    modified: Option<SystemTime>,
8808) {
8809    if let Some(modified) = modified {
8810        if stats.newest.is_none_or(|newest| modified > newest) {
8811            stats.newest = Some(modified);
8812        }
8813    }
8814}
8815
8816fn artifact_key_looks_valid(key: &str) -> bool {
8817    key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8818}
8819
8820#[cfg(test)]
8821fn reset_callgraph_root_sweep_cursor_for_test() {
8822    if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8823        cursors.lock().unwrap().clear();
8824    }
8825}
8826
8827/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
8828///
8829/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
8830/// place on success; a build that dies (process kill, crash, host restart) leaves
8831/// the temporary behind. The largest observed cold build finishes well under a
8832/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
8833/// never rename. A live build's temporary is minutes old at most.
8834///
8835/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
8836/// liveness check read false-positive on exactly the oldest files — the ones most
8837/// worth deleting: in production an orphan's embedded pid had been recycled to an
8838/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
8839/// cannot lie that way, so it is the honest orphan predicate.
8840const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8841
8842/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
8843/// same cadence as [`gc_old_generations`] (after a generation is published) but,
8844/// unlike it, is not scoped to the building root: it covers every directory in the
8845/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
8846///
8847/// That last case is the production hole this fixes. The per-root cleanup in
8848/// [`gc_old_generations`] only fires when a root actually builds, so when activity
8849/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
8850/// store's orphans become permanent — gigabytes accumulated in a legacy store
8851/// whose roots no longer built there, while the active store stayed clean. A
8852/// sibling root that still builds triggers this pass and cleans both layouts.
8853fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8854    sweep_orphaned_build_temps(callgraph_dir);
8855    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8856        return;
8857    };
8858    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8859    // A vanished mounted child can make ReadDir::drop panic after closedir
8860    // returns ENXIO, aborting the daemon. Keep the store-wide background sweep
8861    // on the storage root's filesystem before opening child directories.
8862    let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8863        crate::slog_warn!(
8864            "cannot establish filesystem boundary for callgraph sweep {}",
8865            storage_root.display()
8866        );
8867        return;
8868    };
8869    let mut skipped_foreign_mounts = 0usize;
8870
8871    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
8872    let root_keyed_dir = storage_root.join(domain);
8873    if root_keyed_dir.is_dir() {
8874        if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8875            if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8876                for entry in entries.flatten() {
8877                    let path = entry.path();
8878                    if path.is_dir() {
8879                        if boundary.should_descend(&path).unwrap_or(false) {
8880                            sweep_orphaned_build_temps(&path);
8881                        } else {
8882                            skipped_foreign_mounts += 1;
8883                        }
8884                    }
8885                }
8886            }
8887        } else {
8888            skipped_foreign_mounts += 1;
8889        }
8890    }
8891
8892    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
8893    if let Ok(entries) = std::fs::read_dir(&storage_root) {
8894        for entry in entries.flatten() {
8895            let harness_dir = entry.path();
8896            if !harness_dir.is_dir() {
8897                continue;
8898            }
8899            if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8900                skipped_foreign_mounts += 1;
8901                continue;
8902            }
8903            let legacy_dir = harness_dir.join(domain);
8904            if legacy_dir.is_dir() {
8905                if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8906                    sweep_orphaned_build_temps(&legacy_dir);
8907                } else {
8908                    skipped_foreign_mounts += 1;
8909                }
8910            }
8911        }
8912    }
8913    if skipped_foreign_mounts > 0 {
8914        crate::slog_warn!(
8915            "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8916            skipped_foreign_mounts,
8917            storage_root.display()
8918        );
8919    }
8920}
8921
8922/// Sweep one callgraph directory, removing build temporaries older than
8923/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
8924fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8925    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8926}
8927
8928/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
8929/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
8930fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8931    let now = SystemTime::now();
8932    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8933        return;
8934    };
8935    let mut removed_any = false;
8936    for entry in entries.flatten() {
8937        let name = entry.file_name().to_string_lossy().to_string();
8938        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
8939        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
8940        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
8941        // without that substring — a completed `.sqlite` generation, a pointer, a
8942        // read-marker dir — is left alone: those belong to generation GC.
8943        if !name.contains(".sqlite.tmp.") {
8944            continue;
8945        }
8946        let mtime = entry
8947            .metadata()
8948            .and_then(|meta| meta.modified())
8949            .unwrap_or(now);
8950        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8951            continue;
8952        }
8953        // Deletion races a concurrent build finishing: that build renames the temp
8954        // into place, so the file is gone by the time we unlink. The 24h age makes
8955        // this overlap practically impossible, but treat a missing file as success
8956        // (the rename won) rather than an error, and never touch a path that does
8957        // not match the temporary shape above.
8958        match std::fs::remove_file(entry.path()) {
8959            Ok(()) => removed_any = true,
8960            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8961            Err(_) => {}
8962        }
8963    }
8964    if removed_any {
8965        crate::fs_lock::sync_parent(callgraph_dir);
8966    }
8967}
8968
8969/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
8970/// the global all-cores rayon pool. The store cold-build is the heaviest
8971/// background pass (parse-dominated) and runs on a separate thread off the
8972/// single-threaded request loop; left unbounded it monopolizes every core and
8973/// starves the bridge so interactive tools time out (the same starvation the
8974/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
8975/// match the main thread, since the extract walks tree-sitter ASTs.
8976fn build_pool_size() -> usize {
8977    std::thread::available_parallelism()
8978        .map(|parallelism| parallelism.get())
8979        .unwrap_or(1)
8980        .div_ceil(2)
8981        .clamp(1, 8)
8982}
8983
8984fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8985    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8986        Ok(extract) => Ok(extract),
8987        Err(error) => {
8988            let abs_path =
8989                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8990            let rel_path = relative_path(project_root, &abs_path);
8991            let freshness = cache_freshness::collect(&abs_path).ok();
8992            log::debug!(
8993                "callgraph store: skipping {} during cold build: {}",
8994                abs_path.display(),
8995                error
8996            );
8997            Err(ExtractFailure {
8998                rel_path,
8999                freshness,
9000            })
9001        }
9002    };
9003
9004    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
9005        files.par_iter().map(extract_one).collect()
9006    };
9007
9008    // Run inside a dedicated bounded pool when one builds; fall back to the
9009    // global pool only if the bounded pool can't be constructed.
9010    let results = match rayon::ThreadPoolBuilder::new()
9011        .num_threads(build_pool_size())
9012        .thread_name(|index| format!("aft-callgraph-build-{index}"))
9013        .stack_size(8 * 1024 * 1024)
9014        .build()
9015    {
9016        Ok(pool) => pool.install(run),
9017        Err(error) => {
9018            log::warn!(
9019                "callgraph store: bounded build pool unavailable ({error}); using global pool"
9020            );
9021            run()
9022        }
9023    };
9024
9025    let mut extracts = Vec::new();
9026    let mut failures = Vec::new();
9027    for result in results {
9028        match result {
9029            Ok(extract) => extracts.push(extract),
9030            Err(failure) => failures.push(failure),
9031        }
9032    }
9033    BuildExtractsResult { extracts, failures }
9034}
9035
9036fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
9037    let metadata = std::fs::metadata(path)?;
9038    let size = metadata.len();
9039    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
9040        cache_freshness::zero_hash()
9041    } else if source.len() as u64 == size {
9042        cache_freshness::hash_bytes(source.as_bytes())
9043    } else {
9044        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
9045    };
9046    Ok(FileFreshness {
9047        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
9048        size,
9049        content_hash,
9050    })
9051}
9052
9053fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
9054    let abs_path = normalize_file_path(project_root, path)?;
9055    let rel_path = relative_path(project_root, &abs_path);
9056    let source = std::fs::read_to_string(&abs_path)?;
9057    let freshness = collect_source_freshness(&abs_path, &source)?;
9058    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
9059    let lang = data.lang;
9060    if lang == LangId::Rust {
9061        extend_rust_imports_with_nested_uses(&source, &mut data);
9062    }
9063    let mut nodes = build_node_records(&rel_path, &source, &data)?;
9064    let node_by_scoped: HashMap<String, String> = nodes
9065        .iter()
9066        .map(|node| (node.scoped_name.clone(), node.id.clone()))
9067        .collect();
9068    let import_dependencies = import_dependencies(
9069        project_root,
9070        &abs_path,
9071        &data.import_block.imports,
9072        &FactPaths {
9073            root: project_root,
9074            facts: &DiskFacts::new(project_root),
9075        },
9076    );
9077    let line_index = LineIndex::new(&source);
9078    let reexports = collect_reexport_refs(
9079        project_root,
9080        &abs_path,
9081        &rel_path,
9082        &source,
9083        &FactPaths {
9084            root: project_root,
9085            facts: &DiskFacts::new(project_root),
9086        },
9087    );
9088    let rust_reexports = if lang == LangId::Rust {
9089        collect_rust_pub_use_reexport_refs(
9090            project_root,
9091            &abs_path,
9092            &rel_path,
9093            &data.import_block.imports,
9094            &line_index,
9095            &FactPaths {
9096                root: project_root,
9097                facts: &DiskFacts::new(project_root),
9098            },
9099        )
9100    } else {
9101        ReexportRefs {
9102            raw_refs: Vec::new(),
9103            surface_parts: Vec::new(),
9104        }
9105    };
9106    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
9107    let mut raw_refs = Vec::new();
9108    raw_refs.extend(build_call_refs(
9109        &rel_path,
9110        &data,
9111        &node_by_scoped,
9112        &import_dependencies,
9113    ));
9114    raw_refs.extend(build_value_ref_refs(
9115        &rel_path,
9116        &data,
9117        &node_by_scoped,
9118        &import_dependencies,
9119    ));
9120    raw_refs.extend(build_import_refs(
9121        project_root,
9122        &abs_path,
9123        &rel_path,
9124        &data.import_block.imports,
9125        &line_index,
9126        &FactPaths {
9127            root: project_root,
9128            facts: &DiskFacts::new(project_root),
9129        },
9130    ));
9131    if lang == LangId::Rust {
9132        raw_refs.extend(build_rust_module_refs(
9133            project_root,
9134            &abs_path,
9135            &rel_path,
9136            &source,
9137            &FactPaths {
9138                root: project_root,
9139                facts: &DiskFacts::new(project_root),
9140            },
9141        ));
9142    }
9143    let mut surface_parts = reexports.surface_parts;
9144    surface_parts.extend(rust_reexports.surface_parts);
9145    surface_parts.extend(source_less_exports.surface_parts);
9146    raw_refs.extend(reexports.raw_refs);
9147    raw_refs.extend(rust_reexports.raw_refs);
9148    raw_refs.extend(source_less_exports.raw_refs);
9149    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
9150    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
9151
9152    Ok(FileExtract {
9153        rel_path,
9154        freshness,
9155        lang,
9156        data,
9157        nodes,
9158        raw_refs,
9159        dispatch_hints,
9160        surface_fingerprint,
9161    })
9162}
9163
9164fn build_node_records(
9165    rel_path: &str,
9166    source: &str,
9167    data: &FileCallData,
9168) -> Result<Vec<NodeRecord>> {
9169    let mut records = Vec::new();
9170    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
9171    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
9172    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
9173
9174    for (scoped_name, meta) in metadata {
9175        let name = unqualified_name(scoped_name).to_string();
9176        let range = selection_range(source, scoped_name, &name, &meta.range);
9177        let range_key = (
9178            range.start_line,
9179            range.start_col,
9180            range.end_line,
9181            range.end_col,
9182        );
9183        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
9184        let range_ordinal = *ordinal;
9185        *ordinal += 1;
9186        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
9187        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
9188        let is_default_export = data
9189            .default_export_symbol
9190            .as_deref()
9191            .map(|default| default == scoped_name || default == name)
9192            .unwrap_or(false);
9193        records.push(NodeRecord {
9194            id,
9195            file_path: rel_path.to_string(),
9196            name: name.clone(),
9197            scoped_name: scoped_name.clone(),
9198            kind: symbol_kind_label(&meta.kind).to_string(),
9199            range,
9200            range_ordinal,
9201            signature: meta.signature.clone(),
9202            exported,
9203            is_default_export,
9204            is_type_like: is_type_like(&meta.kind),
9205            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
9206                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
9207        });
9208    }
9209
9210    Ok(records)
9211}
9212
9213fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
9214    if scoped_name == TOP_LEVEL_SYMBOL {
9215        return Range {
9216            start_line: 0,
9217            start_col: 0,
9218            end_line: 0,
9219            end_col: 0,
9220        };
9221    }
9222    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
9223        return fallback.clone();
9224    };
9225    let start_col = fallback.start_col as usize;
9226    let search_start = start_col.min(line.len());
9227    if let Some(offset) = line[search_start..].find(name) {
9228        let col = search_start + offset;
9229        return Range {
9230            start_line: fallback.start_line,
9231            start_col: col as u32,
9232            end_line: fallback.start_line,
9233            end_col: (col + name.len()) as u32,
9234        };
9235    }
9236    if let Some(offset) = line.find(name) {
9237        return Range {
9238            start_line: fallback.start_line,
9239            start_col: offset as u32,
9240            end_line: fallback.start_line,
9241            end_col: (offset + name.len()) as u32,
9242        };
9243    }
9244    Range {
9245        start_line: fallback.start_line,
9246        start_col: fallback.start_col,
9247        end_line: fallback.start_line,
9248        end_col: fallback.start_col.saturating_add(name.len() as u32),
9249    }
9250}
9251
9252fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
9253    if scoped_name == TOP_LEVEL_SYMBOL {
9254        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
9255    }
9256    let input = format!(
9257        "{rel_path}:{}:{}:{}:{}:{ordinal}",
9258        range.start_line, range.start_col, range.end_line, range.end_col
9259    );
9260    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
9261}
9262
9263fn build_call_refs(
9264    rel_path: &str,
9265    data: &FileCallData,
9266    node_by_scoped: &HashMap<String, String>,
9267    import_dependencies: &BTreeSet<String>,
9268) -> Vec<RawRef> {
9269    build_callable_refs(
9270        rel_path,
9271        &data.calls_by_symbol,
9272        node_by_scoped,
9273        import_dependencies,
9274        "call",
9275    )
9276}
9277
9278fn build_value_ref_refs(
9279    rel_path: &str,
9280    data: &FileCallData,
9281    node_by_scoped: &HashMap<String, String>,
9282    import_dependencies: &BTreeSet<String>,
9283) -> Vec<RawRef> {
9284    build_callable_refs(
9285        rel_path,
9286        &data.value_refs_by_symbol,
9287        node_by_scoped,
9288        import_dependencies,
9289        "value_ref",
9290    )
9291}
9292
9293fn build_callable_refs(
9294    rel_path: &str,
9295    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
9296    node_by_scoped: &HashMap<String, String>,
9297    import_dependencies: &BTreeSet<String>,
9298    kind: &str,
9299) -> Vec<RawRef> {
9300    let mut refs = Vec::new();
9301    let mut ordinal = 0usize;
9302    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
9303    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
9304    for (caller_symbol, call_sites) in symbols {
9305        let caller_node = node_by_scoped.get(caller_symbol).cloned();
9306        for call_site in call_sites {
9307            ordinal += 1;
9308            let ref_id = ref_id(&[
9309                rel_path,
9310                kind,
9311                caller_symbol,
9312                &call_site.line.to_string(),
9313                &call_site.byte_start.to_string(),
9314                &call_site.byte_end.to_string(),
9315                &call_site.full_callee,
9316                &ordinal.to_string(),
9317            ]);
9318            refs.push(RawRef {
9319                ref_id,
9320                caller_node: caller_node.clone(),
9321                caller_symbol: Some(caller_symbol.clone()),
9322                caller_file: rel_path.to_string(),
9323                kind: kind.to_string(),
9324                short_name: Some(call_site.callee_name.clone()),
9325                full_ref: Some(call_site.full_callee.clone()),
9326                module_path: None,
9327                import_kind: None,
9328                local_name: Some(call_site.callee_name.clone()),
9329                requested_name: Some(call_site.callee_name.clone()),
9330                namespace_alias: namespace_alias(&call_site.full_callee),
9331                wildcard: false,
9332                line: call_site.line,
9333                byte_start: call_site.byte_start,
9334                byte_end: call_site.byte_end,
9335                dependencies: import_dependencies.clone(),
9336            });
9337        }
9338    }
9339    refs
9340}
9341
9342fn build_import_refs(
9343    project_root: &Path,
9344    abs_path: &Path,
9345    rel_path: &str,
9346    imports: &[ImportStatement],
9347    line_index: &LineIndex,
9348    facts: &FactPaths<'_>,
9349) -> Vec<RawRef> {
9350    let mut refs = Vec::new();
9351    for (index, import) in imports.iter().enumerate() {
9352        let import_kind = import_kind_label(import.kind).to_string();
9353        let local_name = import_local_names(import).join(",");
9354        let requested_name = import_requested_names(import).join(",");
9355        let ref_id = ref_id(&[
9356            rel_path,
9357            "import",
9358            &import.byte_range.start.to_string(),
9359            &import.byte_range.end.to_string(),
9360            &import.module_path,
9361            &index.to_string(),
9362        ]);
9363        refs.push(RawRef {
9364            ref_id,
9365            caller_node: None,
9366            caller_symbol: None,
9367            caller_file: rel_path.to_string(),
9368            kind: "import".to_string(),
9369            short_name: None,
9370            full_ref: Some(import.raw_text.clone()),
9371            module_path: Some(import.module_path.clone()),
9372            import_kind: Some(import_kind),
9373            local_name: empty_to_none(local_name),
9374            requested_name: empty_to_none(requested_name),
9375            namespace_alias: import.namespace_import.clone(),
9376            wildcard: import_is_wildcard(import),
9377            line: line_index.byte_to_line(import.byte_range.start),
9378            byte_start: import.byte_range.start,
9379            byte_end: import.byte_range.end,
9380            dependencies: module_dependencies(project_root, abs_path, &import.module_path, facts),
9381        });
9382    }
9383    refs
9384}
9385
9386fn build_rust_module_refs(
9387    project_root: &Path,
9388    abs_path: &Path,
9389    rel_path: &str,
9390    source: &str,
9391    facts: &FactPaths<'_>,
9392) -> Vec<RawRef> {
9393    let grammar = grammar_for(LangId::Rust);
9394    let mut parser = Parser::new();
9395    if parser.set_language(&grammar).is_err() {
9396        return Vec::new();
9397    }
9398    let Some(tree) = parser.parse(source, None) else {
9399        return Vec::new();
9400    };
9401
9402    let mut refs = Vec::new();
9403    let mut stack = vec![tree.root_node()];
9404    while let Some(node) = stack.pop() {
9405        if node.kind() == "mod_item"
9406            && node
9407                .named_children(&mut node.walk())
9408                .all(|child| child.kind() != "declaration_list")
9409        {
9410            if let Some(name_node) = node.child_by_field_name("name") {
9411                let module_name = node_text(name_node, source).to_string();
9412                let target = rust_external_module_target(
9413                    abs_path,
9414                    rust_module_path_override(source, node),
9415                    &module_name,
9416                    facts,
9417                );
9418                let mut dependencies = BTreeSet::new();
9419                if let Some(target) = target {
9420                    dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
9421                }
9422                refs.push(RawRef {
9423                    ref_id: ref_id(&[
9424                        rel_path,
9425                        "module",
9426                        &module_name,
9427                        &node.start_byte().to_string(),
9428                    ]),
9429                    caller_node: None,
9430                    caller_symbol: None,
9431                    caller_file: rel_path.to_string(),
9432                    kind: "module".to_string(),
9433                    short_name: Some(module_name.clone()),
9434                    full_ref: Some(module_name.clone()),
9435                    module_path: Some(module_name.clone()),
9436                    import_kind: Some("module".to_string()),
9437                    local_name: Some(module_name.clone()),
9438                    requested_name: Some(module_name),
9439                    namespace_alias: None,
9440                    wildcard: false,
9441                    line: node.start_position().row as u32 + 1,
9442                    byte_start: node.start_byte(),
9443                    byte_end: node.end_byte(),
9444                    dependencies,
9445                });
9446            }
9447        }
9448
9449        let mut cursor = node.walk();
9450        if cursor.goto_first_child() {
9451            loop {
9452                stack.push(cursor.node());
9453                if !cursor.goto_next_sibling() {
9454                    break;
9455                }
9456            }
9457        }
9458    }
9459    refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
9460    refs
9461}
9462
9463fn rust_declared_module_target(
9464    project_root: &Path,
9465    caller_file: &str,
9466    module_name: &str,
9467    memo: &callgraph::ModuleResolutionMemo,
9468    facts: &FactPaths<'_>,
9469) -> Option<String> {
9470    memo.rust_declared_module_target(caller_file, module_name, || {
9471        rust_declared_module_targets(project_root, caller_file, facts)
9472    })
9473}
9474
9475fn rust_declared_module_targets(
9476    project_root: &Path,
9477    caller_file: &str,
9478    facts: &FactPaths<'_>,
9479) -> HashMap<String, Option<String>> {
9480    let declaring_file = project_root.join(caller_file);
9481    let Ok(source) = std::fs::read_to_string(&declaring_file) else {
9482        return HashMap::new();
9483    };
9484    let Ok(tree) = parse_source_with_cached_parser(&declaring_file, &source, LangId::Rust) else {
9485        return HashMap::new();
9486    };
9487    let mut targets = HashMap::new();
9488    let mut stack = vec![tree.root_node()];
9489    while let Some(node) = stack.pop() {
9490        if node.kind() == "mod_item"
9491            && node
9492                .named_children(&mut node.walk())
9493                .all(|child| child.kind() != "declaration_list")
9494        {
9495            if let Some(name) = node.child_by_field_name("name") {
9496                let module_name = node_text(name, &source);
9497                let target = rust_external_module_target(
9498                    &declaring_file,
9499                    rust_module_path_override(&source, node),
9500                    module_name,
9501                    facts,
9502                )
9503                .map(|target| {
9504                    relative_path(
9505                        project_root,
9506                        &facts.canonical(&target).unwrap_or(target.clone()),
9507                    )
9508                });
9509                targets.entry(module_name.to_string()).or_insert(target);
9510            }
9511        }
9512        let mut cursor = node.walk();
9513        if cursor.goto_first_child() {
9514            loop {
9515                stack.push(cursor.node());
9516                if !cursor.goto_next_sibling() {
9517                    break;
9518                }
9519            }
9520        }
9521    }
9522    targets
9523}
9524
9525fn rust_external_module_target(
9526    declaring_file: &Path,
9527    path_override: Option<&str>,
9528    module_name: &str,
9529    facts: &FactPaths<'_>,
9530) -> Option<PathBuf> {
9531    let parent = declaring_file.parent()?;
9532    if let Some(path) = path_override {
9533        let candidate = parent.join(path);
9534        return facts.is_file(&candidate).then_some(candidate);
9535    }
9536
9537    let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
9538    let declaring_file = facts
9539        .canonical(declaring_file)
9540        .unwrap_or_else(|| declaring_file.to_path_buf());
9541    let is_crate_root = callgraph::rust_crate_root_file_for_caller(
9542        facts.root,
9543        &declaring_file,
9544        facts,
9545        &callgraph::RustCrateRootMemo::default(),
9546    )
9547    .as_ref()
9548        == Some(&declaring_file);
9549    let module_dir = if matches!(stem, "lib" | "main" | "mod") || is_crate_root {
9550        parent.to_path_buf()
9551    } else {
9552        parent.join(stem)
9553    };
9554    [
9555        module_dir.join(format!("{module_name}.rs")),
9556        module_dir.join(module_name).join("mod.rs"),
9557    ]
9558    .into_iter()
9559    .find(|candidate| facts.is_file(candidate))
9560}
9561
9562fn rust_module_path_override<'a>(source: &'a str, module: Node<'_>) -> Option<&'a str> {
9563    let mut previous = module.prev_sibling();
9564    while let Some(attribute) = previous {
9565        if attribute.kind() != "attribute_item" {
9566            break;
9567        }
9568        if let Some(path) = rust_path_attribute(source.get(attribute.byte_range())?) {
9569            return Some(path);
9570        }
9571        previous = attribute.prev_sibling();
9572    }
9573    None
9574}
9575
9576fn rust_path_attribute(attribute: &str) -> Option<&str> {
9577    let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
9578    let (name, value) = body.split_once('=')?;
9579    (name.trim() == "path")
9580        .then(|| value.trim().trim_matches('"'))
9581        .filter(|path| !path.is_empty())
9582}
9583
9584fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
9585    let grammar = grammar_for(LangId::Rust);
9586    let mut parser = Parser::new();
9587    if parser.set_language(&grammar).is_err() {
9588        return;
9589    }
9590    let Some(tree) = parser.parse(source, None) else {
9591        return;
9592    };
9593
9594    let mut seen = data
9595        .import_block
9596        .imports
9597        .iter()
9598        .map(|import| (import.byte_range.start, import.byte_range.end))
9599        .collect::<HashSet<_>>();
9600    let mut nested_imports = Vec::new();
9601    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9602    if nested_imports.is_empty() {
9603        return;
9604    }
9605
9606    data.import_block.imports.extend(nested_imports);
9607    data.import_block
9608        .imports
9609        .sort_by_key(|import| import.byte_range.start);
9610    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9611}
9612
9613fn collect_rust_use_imports(
9614    source: &str,
9615    node: Node<'_>,
9616    seen: &mut HashSet<(usize, usize)>,
9617    imports: &mut Vec<ImportStatement>,
9618) {
9619    if node.kind() == "use_declaration" {
9620        let range = node.byte_range();
9621        if seen.insert((range.start, range.end)) {
9622            if let Some(import) = rust_import_from_use_node(source, node) {
9623                imports.push(import);
9624            }
9625        }
9626    }
9627
9628    let mut cursor = node.walk();
9629    if !cursor.goto_first_child() {
9630        return;
9631    }
9632    loop {
9633        collect_rust_use_imports(source, cursor.node(), seen, imports);
9634        if !cursor.goto_next_sibling() {
9635            break;
9636        }
9637    }
9638}
9639
9640fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9641    let raw_text = source[node.byte_range()].to_string();
9642    let body = rust_use_body(&raw_text)?.to_string();
9643    let visibility = rust_use_visibility(&raw_text);
9644    let names = rust_use_list_names(&body);
9645    let group = classify_rust_import_group(&body);
9646    let byte_range = node.byte_range();
9647
9648    Some(ImportStatement {
9649        module_path: body,
9650        names: names.clone(),
9651        default_import: visibility.clone(),
9652        namespace_import: None,
9653        kind: ImportKind::Value,
9654        group,
9655        byte_range,
9656        raw_text,
9657        form: ImportForm::RustUse {
9658            visibility,
9659            named: names,
9660        },
9661    })
9662}
9663
9664fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9665    let start = imports.iter().map(|import| import.byte_range.start).min()?;
9666    let end = imports.iter().map(|import| import.byte_range.end).max()?;
9667    Some(start..end)
9668}
9669
9670fn rust_use_visibility(raw_text: &str) -> Option<String> {
9671    let use_pos = raw_text.find("use ")?;
9672    let prefix = raw_text[..use_pos].trim();
9673    if prefix.is_empty() {
9674        None
9675    } else {
9676        Some(prefix.to_string())
9677    }
9678}
9679
9680fn rust_use_body(raw_text: &str) -> Option<&str> {
9681    let use_pos = raw_text.find("use ")?;
9682    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9683}
9684
9685fn rust_use_list_names(body: &str) -> Vec<String> {
9686    let Some(open) = body.find("::{") else {
9687        return Vec::new();
9688    };
9689    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9690        return Vec::new();
9691    };
9692    body[open + 3..close]
9693        .split(',')
9694        .filter_map(|spec| {
9695            let spec = spec.trim();
9696            if spec.is_empty() {
9697                None
9698            } else {
9699                Some(spec.to_string())
9700            }
9701        })
9702        .collect()
9703}
9704
9705fn classify_rust_import_group(body: &str) -> ImportGroup {
9706    let first = body
9707        .split("::")
9708        .next()
9709        .unwrap_or(body)
9710        .split_whitespace()
9711        .next()
9712        .unwrap_or(body);
9713    match first.trim() {
9714        "std" | "core" | "alloc" => ImportGroup::Stdlib,
9715        "crate" | "self" | "super" => ImportGroup::Internal,
9716        _ => ImportGroup::External,
9717    }
9718}
9719
9720#[derive(Debug, Clone)]
9721struct ReexportRefs {
9722    raw_refs: Vec<RawRef>,
9723    surface_parts: Vec<String>,
9724}
9725
9726fn collect_reexport_refs(
9727    project_root: &Path,
9728    abs_path: &Path,
9729    rel_path: &str,
9730    source: &str,
9731    facts: &FactPaths<'_>,
9732) -> ReexportRefs {
9733    let mut raw_refs = Vec::new();
9734    let mut surface_parts = Vec::new();
9735    let mut search_start = 0usize;
9736    let mut ordinal = 0usize;
9737    while let Some(export_offset) = source[search_start..].find("export") {
9738        let start = search_start + export_offset;
9739        let Some(statement_end_offset) = source[start..].find(';') else {
9740            break;
9741        };
9742        let end = start + statement_end_offset + 1;
9743        let statement = &source[start..end];
9744        search_start = end;
9745        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9746            continue;
9747        }
9748        let Some(module_path) = quoted_module_path(statement) else {
9749            continue;
9750        };
9751        ordinal += 1;
9752        let wildcard = statement.contains('*');
9753        let line = source[..start]
9754            .bytes()
9755            .filter(|byte| *byte == b'\n')
9756            .count() as u32
9757            + 1;
9758        let ref_id = ref_id(&[
9759            rel_path,
9760            "reexport",
9761            &start.to_string(),
9762            &end.to_string(),
9763            &module_path,
9764            &ordinal.to_string(),
9765        ]);
9766        surface_parts.push(format!("reexport\t{statement}"));
9767        raw_refs.push(RawRef {
9768            ref_id,
9769            caller_node: None,
9770            caller_symbol: None,
9771            caller_file: rel_path.to_string(),
9772            kind: "reexport".to_string(),
9773            short_name: None,
9774            full_ref: Some(statement.to_string()),
9775            module_path: Some(module_path.clone()),
9776            import_kind: Some("reexport".to_string()),
9777            local_name: None,
9778            requested_name: None,
9779            namespace_alias: None,
9780            wildcard,
9781            line,
9782            byte_start: start,
9783            byte_end: end,
9784            dependencies: module_dependencies(project_root, abs_path, &module_path, facts),
9785        });
9786    }
9787    ReexportRefs {
9788        raw_refs,
9789        surface_parts,
9790    }
9791}
9792
9793fn collect_rust_pub_use_reexport_refs(
9794    project_root: &Path,
9795    abs_path: &Path,
9796    rel_path: &str,
9797    imports: &[ImportStatement],
9798    line_index: &LineIndex,
9799    facts: &FactPaths<'_>,
9800) -> ReexportRefs {
9801    let mut raw_refs = Vec::new();
9802    let mut surface_parts = Vec::new();
9803    let mut ordinal = 0usize;
9804
9805    for import in imports {
9806        let Some(visibility) = &import.default_import else {
9807            continue;
9808        };
9809        if !visibility.starts_with("pub") {
9810            continue;
9811        }
9812        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9813            continue;
9814        };
9815        ordinal += 1;
9816        let ref_id = ref_id(&[
9817            rel_path,
9818            "rust_reexport",
9819            &import.byte_range.start.to_string(),
9820            &import.byte_range.end.to_string(),
9821            &module_path,
9822            &ordinal.to_string(),
9823        ]);
9824        surface_parts.push(format!("reexport\t{}", import.raw_text));
9825        raw_refs.push(RawRef {
9826            ref_id,
9827            caller_node: None,
9828            caller_symbol: None,
9829            caller_file: rel_path.to_string(),
9830            kind: "reexport".to_string(),
9831            short_name: None,
9832            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9833            module_path: Some(module_path.clone()),
9834            import_kind: Some("reexport".to_string()),
9835            local_name: None,
9836            requested_name: None,
9837            namespace_alias: None,
9838            wildcard,
9839            line: line_index.byte_to_line(import.byte_range.start),
9840            byte_start: import.byte_range.start,
9841            byte_end: import.byte_range.end,
9842            dependencies: rust_module_dependencies(project_root, abs_path, &module_path, facts),
9843        });
9844    }
9845
9846    ReexportRefs {
9847        raw_refs,
9848        surface_parts,
9849    }
9850}
9851
9852fn rust_pub_use_reexport_parts(
9853    import: &ImportStatement,
9854) -> Option<(String, HashMap<String, String>, bool)> {
9855    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9856    let body = body.trim();
9857    if let Some(module_path) = body.strip_suffix("::*") {
9858        return Some((module_path.trim().to_string(), HashMap::new(), true));
9859    }
9860
9861    if let Some(brace_start) = body.find("::{") {
9862        let module_path = body[..brace_start].trim().to_string();
9863        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9864        if names.is_empty() {
9865            return None;
9866        }
9867        return Some((module_path, names, false));
9868    }
9869
9870    let (module_path, spec) = body.rsplit_once("::")?;
9871    let names = rust_reexport_names_from_specs(spec);
9872    if names.is_empty() {
9873        return None;
9874    }
9875    Some((module_path.trim().to_string(), names, false))
9876}
9877
9878fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9879    let mut names = HashMap::new();
9880    for spec in specs.split(',') {
9881        let spec = spec.trim();
9882        if spec.is_empty() || spec == "self" {
9883            continue;
9884        }
9885        if let Some((source, local)) = spec.split_once(" as ") {
9886            let source = source.trim();
9887            let local = local.trim();
9888            if !source.is_empty() && !local.is_empty() && source != "self" {
9889                names.insert(local.to_string(), source.to_string());
9890            }
9891        } else {
9892            names.insert(spec.to_string(), spec.to_string());
9893        }
9894    }
9895    names
9896}
9897
9898fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9899    if named.is_empty() {
9900        return fallback.to_string();
9901    }
9902    let mut specs = named
9903        .iter()
9904        .map(|(local, source)| {
9905            if local == source {
9906                source.clone()
9907            } else {
9908                format!("{source} as {local}")
9909            }
9910        })
9911        .collect::<Vec<_>>();
9912    specs.sort();
9913    format!("pub use {{{}}};", specs.join(", "))
9914}
9915
9916fn quoted_module_path(statement: &str) -> Option<String> {
9917    let quote = match (statement.find('\''), statement.find('"')) {
9918        (Some(single), Some(double)) if single < double => '\'',
9919        (Some(_), Some(_)) => '"',
9920        (Some(_), None) => '\'',
9921        (None, Some(_)) => '"',
9922        (None, None) => return None,
9923    };
9924    let start = statement.find(quote)? + 1;
9925    let end = statement[start..].find(quote)? + start;
9926    Some(statement[start..end].to_string())
9927}
9928
9929#[derive(Debug, Clone)]
9930struct SourceLessExportRefs {
9931    raw_refs: Vec<RawRef>,
9932    surface_parts: Vec<String>,
9933}
9934
9935fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9936    let mut raw_refs = Vec::new();
9937    let mut surface_parts = Vec::new();
9938    let mut search_start = 0usize;
9939    let mut ordinal = 0usize;
9940    while let Some(export_offset) = source[search_start..].find("export") {
9941        let start = search_start + export_offset;
9942        let Some(statement_end_offset) = source[start..].find(';') else {
9943            break;
9944        };
9945        let end = start + statement_end_offset + 1;
9946        let statement = &source[start..end];
9947        search_start = end;
9948        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9949            continue;
9950        }
9951        let aliases = parse_reexport_names(statement);
9952        if aliases.is_empty() {
9953            continue;
9954        }
9955        let line = source[..start]
9956            .bytes()
9957            .filter(|byte| *byte == b'\n')
9958            .count() as u32
9959            + 1;
9960        for (exported, source_symbol) in aliases {
9961            ordinal += 1;
9962            let ref_id = ref_id(&[
9963                rel_path,
9964                "export_alias",
9965                &start.to_string(),
9966                &end.to_string(),
9967                &exported,
9968                &source_symbol,
9969                &ordinal.to_string(),
9970            ]);
9971            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9972            raw_refs.push(RawRef {
9973                ref_id,
9974                caller_node: None,
9975                caller_symbol: None,
9976                caller_file: rel_path.to_string(),
9977                kind: "export_alias".to_string(),
9978                short_name: None,
9979                full_ref: Some(statement.to_string()),
9980                module_path: None,
9981                import_kind: Some("export_alias".to_string()),
9982                local_name: Some(exported),
9983                requested_name: Some(source_symbol),
9984                namespace_alias: None,
9985                wildcard: false,
9986                line,
9987                byte_start: start,
9988                byte_end: end,
9989                dependencies: BTreeSet::new(),
9990            });
9991        }
9992    }
9993    SourceLessExportRefs {
9994        raw_refs,
9995        surface_parts,
9996    }
9997}
9998
9999fn build_dispatch_hints(
10000    rel_path: &str,
10001    data: &FileCallData,
10002    node_by_scoped: &HashMap<String, String>,
10003) -> Vec<DispatchHint> {
10004    let mut hints = Vec::new();
10005    let mut ordinal = 0usize;
10006    let mut calls_by_symbol = data.calls_by_symbol.iter().collect::<Vec<_>>();
10007    calls_by_symbol.sort_unstable_by(|left, right| left.0.cmp(right.0));
10008    for (caller_symbol, call_sites) in calls_by_symbol {
10009        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
10010            continue;
10011        };
10012        for call_site in call_sites {
10013            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
10014                continue;
10015            }
10016            ordinal += 1;
10017            hints.push(DispatchHint {
10018                id: ref_id(&[
10019                    rel_path,
10020                    "dispatch",
10021                    caller_symbol,
10022                    &call_site.line.to_string(),
10023                    &call_site.byte_start.to_string(),
10024                    &call_site.byte_end.to_string(),
10025                    &ordinal.to_string(),
10026                ]),
10027                method_name: call_site.callee_name.clone(),
10028                caller_node: caller_node.clone(),
10029                file: rel_path.to_string(),
10030                line: call_site.line,
10031                byte_start: call_site.byte_start,
10032                byte_end: call_site.byte_end,
10033            });
10034        }
10035    }
10036    hints
10037}
10038
10039fn surface_fingerprint(
10040    nodes: &mut [NodeRecord],
10041    data: &FileCallData,
10042    reexport_parts: &[String],
10043) -> String {
10044    nodes.sort_by(|left, right| {
10045        (left.file_path.as_str(), left.scoped_name.as_str())
10046            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
10047    });
10048    let mut parts = Vec::new();
10049    for node in nodes.iter() {
10050        parts.push(format!(
10051            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
10052            node.scoped_name,
10053            node.name,
10054            node.kind,
10055            node.exported,
10056            node.range.start_line,
10057            node.range.start_col,
10058            node.range.end_line,
10059            node.range.end_col,
10060            node.range_ordinal,
10061            node.signature.as_deref().unwrap_or("")
10062        ));
10063    }
10064    let mut exports = data.exported_symbols.clone();
10065    exports.sort();
10066    for export in exports {
10067        parts.push(format!("export\t{export}"));
10068    }
10069    if let Some(default_export) = &data.default_export_symbol {
10070        parts.push(format!("default\t{default_export}"));
10071    }
10072    let mut imports: Vec<String> = data
10073        .import_block
10074        .imports
10075        .iter()
10076        .map(|import| {
10077            format!(
10078                "import\t{}\t{:?}\t{}",
10079                import.module_path, import.form, import.raw_text
10080            )
10081        })
10082        .collect();
10083    imports.sort();
10084    parts.extend(imports);
10085    parts.extend(reexport_parts.iter().cloned());
10086    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
10087}
10088
10089fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
10090    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
10091        return Ok(ResolvedRef {
10092            dependencies: raw.dependencies.clone(),
10093            raw,
10094            status: "unresolved".to_string(),
10095            target_node: None,
10096            target_file: None,
10097            target_symbol: None,
10098            edge: None,
10099        });
10100    }
10101
10102    let caller_file = raw.caller_file.clone();
10103    let caller_data =
10104        index
10105            .caller_data(&caller_file)
10106            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
10107                file: caller_file.clone(),
10108            })?;
10109    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
10110    let short_name = raw.short_name.as_deref().unwrap_or_default();
10111    let mut dependencies = raw.dependencies.clone();
10112
10113    let resolved = match index.lang_for(&caller_file) {
10114        Some(LangId::Rust) => {
10115            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
10116        }
10117        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
10118            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
10119        }
10120        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
10121    };
10122
10123    let Some((status, target_file, target_symbol)) = resolved else {
10124        return Ok(ResolvedRef {
10125            raw,
10126            status: "unresolved".to_string(),
10127            target_node: None,
10128            target_file: None,
10129            target_symbol: None,
10130            dependencies,
10131            edge: None,
10132        });
10133    };
10134
10135    dependencies.insert(target_file.clone());
10136    let target_node = index.node_for_symbol(&target_file, &target_symbol);
10137    if raw.kind == "value_ref"
10138        && !target_node
10139            .as_deref()
10140            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
10141    {
10142        return Ok(ResolvedRef {
10143            raw,
10144            status: "unresolved".to_string(),
10145            target_node: None,
10146            target_file: None,
10147            target_symbol: None,
10148            dependencies,
10149            edge: None,
10150        });
10151    }
10152    let source_node = raw.caller_node.clone();
10153    let edge = if let Some(source_node) = source_node {
10154        if target_file == caller_file
10155            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
10156        {
10157            None
10158        } else {
10159            Some(EdgeRecord {
10160                edge_id: ref_id(&[&raw.ref_id, "edge"]),
10161                source_node,
10162                target_node: target_node.clone(),
10163                target_file: target_file.clone(),
10164                target_symbol: target_symbol.clone(),
10165                kind: raw.kind.clone(),
10166                line: raw.line,
10167            })
10168        }
10169    } else {
10170        None
10171    };
10172
10173    Ok(ResolvedRef {
10174        raw,
10175        status,
10176        target_node,
10177        target_file: Some(target_file),
10178        target_symbol: Some(target_symbol),
10179        dependencies,
10180        edge,
10181    })
10182}
10183
10184fn resolve_js_ts_target<I: ResolverIndex>(
10185    index: &I,
10186    caller_file: &str,
10187    full_ref: &str,
10188    short_name: &str,
10189    caller_data: &FileCallData,
10190) -> Option<(String, String, String)> {
10191    if let Some((namespace, member)) = full_ref.split_once('.') {
10192        for import in &caller_data.import_block.imports {
10193            if import.namespace_import.as_deref() == Some(namespace) {
10194                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10195                    if let Some((file, symbol)) =
10196                        resolve_exported_symbol(index, &target_file, member, 0)
10197                    {
10198                        return Some(("resolved".to_string(), file, symbol));
10199                    }
10200                }
10201            }
10202        }
10203    }
10204
10205    for import in &caller_data.import_block.imports {
10206        for spec in &import.names {
10207            if crate::imports::specifier_local_name(spec) == short_name {
10208                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10209                    let requested = crate::imports::specifier_imported_name(spec);
10210                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
10211                        .unwrap_or_else(|| (target_file, requested.to_string()));
10212                    return Some(("resolved".to_string(), file, symbol));
10213                }
10214            }
10215        }
10216
10217        if import.default_import.as_deref() == Some(short_name) {
10218            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10219                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
10220                    .or_else(|| {
10221                        index
10222                            .default_export(&target_file)
10223                            .map(|symbol| (target_file.clone(), symbol))
10224                    })
10225                    .unwrap_or_else(|| {
10226                        let file_name = Path::new(&target_file)
10227                            .file_name()
10228                            .and_then(|name| name.to_str())
10229                            .unwrap_or("unknown")
10230                            .to_string();
10231                        (target_file, format!("<default:{file_name}>"))
10232                    });
10233                return Some(("resolved".to_string(), file, symbol));
10234            }
10235        }
10236    }
10237
10238    for import in &caller_data.import_block.imports {
10239        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10240            if index.has_export(&target_file, short_name) {
10241                return Some(("resolved".to_string(), target_file, short_name.to_string()));
10242            }
10243        }
10244    }
10245
10246    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10247}
10248
10249fn resolve_exported_symbol<I: ResolverIndex>(
10250    index: &I,
10251    file: &str,
10252    requested: &str,
10253    depth: usize,
10254) -> Option<(String, String)> {
10255    let mut visited = std::collections::HashMap::new();
10256    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
10257}
10258
10259/// Re-export graphs are frequently cyclic (barrel files re-exporting each
10260/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
10261/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
10262/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
10263/// (file, symbol) pair — but only when the earlier visit had at least as much
10264/// remaining depth budget (a shallower re-visit can reach leaves the deeper
10265/// first visit had to cut off at the cap, so plain visited-set pruning would
10266/// lose resolutions the capped walk finds).
10267fn resolve_exported_symbol_inner<I: ResolverIndex>(
10268    index: &I,
10269    file: &str,
10270    requested: &str,
10271    depth: usize,
10272    visited: &mut std::collections::HashMap<(String, String), usize>,
10273) -> Option<(String, String)> {
10274    if depth > 16 {
10275        return None;
10276    }
10277    if requested != "default" {
10278        if let Some(source_symbol) = index.export_alias(file, requested) {
10279            return Some((file.to_string(), source_symbol));
10280        }
10281        if index.has_export(file, requested) {
10282            return Some((file.to_string(), requested.to_string()));
10283        }
10284    } else if let Some(default) = index.default_export(file) {
10285        return Some((file.to_string(), default));
10286    }
10287
10288    // Memo check sits after the local-export fast paths: the common direct
10289    // hit never allocates the key, and a hit through the memo would have
10290    // returned above anyway.
10291    match visited.entry((file.to_string(), requested.to_string())) {
10292        std::collections::hash_map::Entry::Occupied(mut seen) => {
10293            if *seen.get() <= depth {
10294                return None;
10295            }
10296            seen.insert(depth);
10297        }
10298        std::collections::hash_map::Entry::Vacant(slot) => {
10299            slot.insert(depth);
10300        }
10301    }
10302
10303    for reexport in index.reexports_for(file) {
10304        let mut next_requested = requested.to_string();
10305        let matches = if reexport.wildcard {
10306            true
10307        } else if let Some(source_name) = reexport.named.get(requested) {
10308            next_requested = source_name.clone();
10309            true
10310        } else {
10311            false
10312        };
10313        if !matches {
10314            continue;
10315        }
10316        if let Some(target_file) = &reexport.target_file {
10317            if let Some(target) = resolve_exported_symbol_inner(
10318                index,
10319                target_file,
10320                &next_requested,
10321                depth + 1,
10322                visited,
10323            ) {
10324                return Some(target);
10325            }
10326        }
10327    }
10328    None
10329}
10330
10331fn resolve_rust_target<I: ResolverIndex>(
10332    index: &I,
10333    caller_file: &str,
10334    full_ref: &str,
10335    short_name: &str,
10336    caller_data: &FileCallData,
10337    raw: &RawRef,
10338) -> Option<(String, String, String)> {
10339    if full_ref.contains("::") {
10340        if let Some((target_file, target_symbol)) =
10341            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
10342        {
10343            return Some(("resolved".to_string(), target_file, target_symbol));
10344        }
10345    }
10346
10347    for import in &caller_data.import_block.imports {
10348        if let Some((target_file, target_symbol)) =
10349            rust_target_for_use(index, caller_file, import, short_name)
10350        {
10351            return Some(("resolved".to_string(), target_file, target_symbol));
10352        }
10353    }
10354
10355    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10356}
10357
10358fn rust_target_for_qualified<I: ResolverIndex>(
10359    index: &I,
10360    caller_file: &str,
10361    full_ref: &str,
10362    short_name: &str,
10363    caller_data: &FileCallData,
10364    raw: &RawRef,
10365) -> Option<(String, String)> {
10366    let mut segments: Vec<&str> = full_ref.split("::").collect();
10367    if segments.len() < 2 {
10368        return None;
10369    }
10370    segments.pop();
10371    let requested_symbol = rust_target_symbol(full_ref, short_name);
10372
10373    for path in rust_module_path_candidates(&segments, caller_data, raw) {
10374        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
10375        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
10376            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
10377                return Some(rust_resolve_reexport_if_symbol_missing(
10378                    index,
10379                    target_file,
10380                    requested_symbol.clone(),
10381                ));
10382            }
10383        }
10384
10385        let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
10386        if let Some(target) =
10387            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
10388        {
10389            return Some(target);
10390        }
10391        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
10392            return Some(rust_resolve_reexport_if_symbol_missing(
10393                index,
10394                target_file,
10395                requested_symbol.clone(),
10396            ));
10397        }
10398    }
10399    None
10400}
10401
10402fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
10403    full_ref
10404        .rsplit("::")
10405        .next()
10406        .filter(|name| !name.is_empty())
10407        .unwrap_or(short_name)
10408        .to_string()
10409}
10410
10411fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
10412    index: &I,
10413    target_file: String,
10414    target_symbol: String,
10415) -> (String, String) {
10416    if index
10417        .node_for_symbol(&target_file, &target_symbol)
10418        .is_some()
10419    {
10420        return (target_file, target_symbol);
10421    }
10422    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
10423        resolved
10424    } else {
10425        (target_file, target_symbol)
10426    }
10427}
10428
10429fn rust_module_path_candidates(
10430    segments: &[&str],
10431    caller_data: &FileCallData,
10432    raw: &RawRef,
10433) -> Vec<Vec<String>> {
10434    let mut candidates = Vec::new();
10435    if let Some(first) = segments.first().copied() {
10436        for import in &caller_data.import_block.imports {
10437            if !rust_import_is_visible_to_call(import, raw) {
10438                continue;
10439            }
10440            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
10441                continue;
10442            };
10443            if local_name == first {
10444                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
10445                rust_push_unique_path_candidate(&mut candidates, path_segments);
10446            }
10447        }
10448    }
10449    rust_push_unique_path_candidate(
10450        &mut candidates,
10451        segments
10452            .iter()
10453            .map(|segment| (*segment).to_string())
10454            .collect(),
10455    );
10456    candidates
10457}
10458
10459fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
10460    if !candidates.iter().any(|existing| existing == &candidate) {
10461        candidates.push(candidate);
10462    }
10463}
10464
10465fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
10466    import.byte_range.start <= raw.byte_start
10467}
10468
10469fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
10470    let path = import.module_path.trim().trim_end_matches(';').trim();
10471    if path.contains("::{") || path.contains('{') || path.contains('*') {
10472        return None;
10473    }
10474    let (path_without_alias, alias) = path
10475        .split_once(" as ")
10476        .map(|(left, right)| (left.trim(), Some(right.trim())))
10477        .unwrap_or((path, None));
10478    let segments = path_without_alias
10479        .split("::")
10480        .map(str::trim)
10481        .filter(|segment| !segment.is_empty())
10482        .collect::<Vec<_>>();
10483    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
10484    if local_name.chars().next().is_some_and(char::is_uppercase) {
10485        return None;
10486    }
10487    Some((
10488        local_name,
10489        segments
10490            .into_iter()
10491            .map(|segment| segment.to_string())
10492            .collect(),
10493    ))
10494}
10495
10496fn rust_inline_scoped_target<I: ResolverIndex>(
10497    index: &I,
10498    caller_file: &str,
10499    module_segments: &[String],
10500    short_name: &str,
10501) -> Option<(String, String)> {
10502    index.inline_scoped_target(caller_file, module_segments, short_name)
10503}
10504
10505fn rust_target_for_use<I: ResolverIndex>(
10506    index: &I,
10507    caller_file: &str,
10508    import: &ImportStatement,
10509    short_name: &str,
10510) -> Option<(String, String)> {
10511    let path = import.module_path.trim().trim_end_matches(';');
10512    if let Some(brace_start) = path.find("::{") {
10513        let prefix = &path[..brace_start];
10514        if import.names.iter().any(|name| name == short_name) {
10515            let prefix_segments: Vec<&str> = prefix.split("::").collect();
10516            let module_segments =
10517                rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
10518            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10519            return Some((file, short_name.to_string()));
10520        }
10521        return None;
10522    }
10523
10524    let (path_without_alias, alias) = path
10525        .split_once(" as ")
10526        .map(|(left, right)| (left.trim(), Some(right.trim())))
10527        .unwrap_or((path, None));
10528    let segments: Vec<&str> = path_without_alias.split("::").collect();
10529    let imported = alias.or_else(|| segments.last().copied())?;
10530    if imported != short_name {
10531        return None;
10532    }
10533    if segments.len() < 2 {
10534        return None;
10535    }
10536    let module_segments =
10537        rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
10538    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10539    Some((file, segments.last().unwrap_or(&short_name).to_string()))
10540}
10541
10542fn rust_workspace_file_for_segments<I: ResolverIndex>(
10543    index: &I,
10544    segments: &[&str],
10545) -> Option<String> {
10546    let crate_name = segments.first().copied()?;
10547    let src_prefix = index.crate_src_prefix(crate_name)?;
10548    let module_segments = segments[1..]
10549        .iter()
10550        .map(|segment| segment.to_string())
10551        .collect::<Vec<_>>();
10552    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
10553}
10554
10555#[cfg(test)]
10556static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
10557    OnceLock::new();
10558
10559#[cfg(test)]
10560fn note_workspace_crate_prefix_build(project_root: &Path) {
10561    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10562        .get_or_init(|| Mutex::new(HashMap::new()))
10563        .lock()
10564        .expect("workspace crate prefix build counts mutex poisoned");
10565    *counts.entry(project_root.to_path_buf()).or_default() += 1;
10566}
10567
10568#[cfg(not(test))]
10569fn note_workspace_crate_prefix_build(_project_root: &Path) {}
10570
10571#[cfg(test)]
10572fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
10573    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10574        .get_or_init(|| Mutex::new(HashMap::new()))
10575        .lock()
10576        .expect("workspace crate prefix build counts mutex poisoned")
10577        .remove(project_root);
10578}
10579
10580#[cfg(test)]
10581fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
10582    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10583        .get_or_init(|| Mutex::new(HashMap::new()))
10584        .lock()
10585        .expect("workspace crate prefix build counts mutex poisoned")
10586        .get(project_root)
10587        .copied()
10588        .unwrap_or(0)
10589}
10590
10591/// Walk the project tree once and map every Rust crate name (package name with
10592/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
10593/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
10594/// longer re-walks the filesystem once per ref.
10595fn build_workspace_crate_prefixes(
10596    project_root: &Path,
10597    facts: &FactPaths<'_>,
10598) -> HashMap<String, String> {
10599    note_workspace_crate_prefix_build(project_root);
10600    let mut prefixes = HashMap::new();
10601    let mut stack = vec![project_root.to_path_buf()];
10602    while let Some(dir) = stack.pop() {
10603        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
10604        if matches!(name, "target" | "node_modules" | ".git") {
10605            continue;
10606        }
10607        let manifest = dir.join("Cargo.toml");
10608        if facts.is_file(&manifest) {
10609            let crate_names = rust_manifest_crate_names(&manifest, facts);
10610            if !crate_names.is_empty() {
10611                let src_prefix = relative_path(
10612                    project_root,
10613                    &facts
10614                        .canonical(&dir.join("src"))
10615                        .unwrap_or_else(|| dir.join("src")),
10616                );
10617                for crate_name in crate_names {
10618                    prefixes
10619                        .entry(crate_name)
10620                        .or_insert_with(|| src_prefix.clone());
10621                }
10622            }
10623        }
10624        for entry in facts.list_dir(&dir) {
10625            if entry.kind == EntryKind::Directory {
10626                stack.push(dir.join(byte_path(&entry.name)));
10627            }
10628        }
10629    }
10630    prefixes
10631}
10632
10633/// Extract the crate names a manifest defines: the normalized package name
10634/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
10635/// reachable by either spelling, matching the previous match semantics.
10636fn rust_manifest_crate_names(manifest: &Path, facts: &FactPaths<'_>) -> Vec<String> {
10637    facts.config_fact(manifest, "manifest.name");
10638    facts.config_fact(manifest, "manifest.lib.name");
10639    let Some(bytes) = facts.attributed_bytes(manifest) else {
10640        return Vec::new();
10641    };
10642    let (package_name, lib_name) = rust_manifest_name_fields(&bytes);
10643    let mut names = Vec::new();
10644    if let Some(lib) = lib_name {
10645        names.push(lib);
10646    }
10647    if let Some(package) = package_name {
10648        let normalized = package.replace('-', "_");
10649        if !names.contains(&normalized) {
10650            names.push(normalized);
10651        }
10652    }
10653    names
10654}
10655
10656/// Preserve the manifest resolver's line-oriented extraction, including its
10657/// first non-lib name and last lib name semantics; this is not a TOML parser.
10658pub(crate) fn rust_manifest_name_fields(bytes: &[u8]) -> (Option<String>, Option<String>) {
10659    let Ok(source) = std::str::from_utf8(bytes) else {
10660        return (None, None);
10661    };
10662    let mut in_lib = false;
10663    let mut package_name = None;
10664    let mut lib_name = None;
10665    for line in source.lines() {
10666        let trimmed = line.trim();
10667        if trimmed.starts_with('[') {
10668            in_lib = trimmed == "[lib]";
10669            continue;
10670        }
10671        let Some((key, value)) = trimmed.split_once('=') else {
10672            continue;
10673        };
10674        let key = key.trim();
10675        let value = value.trim().trim_matches('"');
10676        if in_lib && key == "name" {
10677            lib_name = Some(value.to_string());
10678        } else if !in_lib && key == "name" && package_name.is_none() {
10679            package_name = Some(value.to_string());
10680        }
10681    }
10682    (package_name, lib_name)
10683}
10684
10685fn rust_resolve_segments_with_index<I: ResolverIndex>(
10686    index: &I,
10687    caller_file: &str,
10688    segments: &[&str],
10689) -> Option<Vec<String>> {
10690    let caller_segments = if index.rust_crate_root_file(caller_file).as_deref() == Some(caller_file)
10691    {
10692        Vec::new()
10693    } else {
10694        rust_registered_module_segments(index, caller_file)
10695            .unwrap_or_else(|| rust_module_segments_for_rel(caller_file))
10696    };
10697    rust_resolve_segments_from(caller_segments, segments)
10698}
10699
10700fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10701    rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10702}
10703
10704fn rust_resolve_segments_from(
10705    caller_segments: Vec<String>,
10706    segments: &[&str],
10707) -> Option<Vec<String>> {
10708    if segments.is_empty() {
10709        return Some(Vec::new());
10710    }
10711    match segments[0] {
10712        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10713        "self" => {
10714            let mut resolved = caller_segments;
10715            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10716            Some(resolved)
10717        }
10718        "super" => {
10719            let mut resolved = caller_segments;
10720            resolved.pop();
10721            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10722            Some(resolved)
10723        }
10724        _ => {
10725            let mut resolved = caller_segments;
10726            resolved.pop();
10727            resolved.extend(segments.iter().map(|item| item.to_string()));
10728            Some(resolved)
10729        }
10730    }
10731}
10732
10733fn rust_registered_module_segments<I: ResolverIndex>(
10734    index: &I,
10735    caller_file: &str,
10736) -> Option<Vec<String>> {
10737    let mut current = caller_file.to_string();
10738    let mut segments = Vec::new();
10739    let mut seen = HashSet::new();
10740    while seen.insert(current.clone()) {
10741        let Some((parent, module)) = index.module_parent(&current) else {
10742            break;
10743        };
10744        segments.push(module);
10745        current = parent;
10746    }
10747    if segments.is_empty() {
10748        None
10749    } else {
10750        segments.reverse();
10751        Some(segments)
10752    }
10753}
10754
10755fn rust_file_for_segments<I: ResolverIndex>(
10756    index: &I,
10757    caller_file: &str,
10758    segments: &[String],
10759) -> Option<String> {
10760    let src_prefix = rust_src_prefix(caller_file);
10761    if let Some(target) =
10762        rust_file_from_module_declarations(index, caller_file, &src_prefix, segments)
10763    {
10764        return Some(target);
10765    }
10766    rust_file_for_src_prefix(index, &src_prefix, segments)
10767}
10768
10769fn rust_file_from_module_declarations<I: ResolverIndex>(
10770    index: &I,
10771    caller_file: &str,
10772    src_prefix: &str,
10773    segments: &[String],
10774) -> Option<String> {
10775    let mut current = index.rust_crate_root_file(caller_file).or_else(|| {
10776        [
10777            format!("{src_prefix}/lib.rs"),
10778            format!("{src_prefix}/main.rs"),
10779        ]
10780        .into_iter()
10781        .find(|candidate| index.contains_file(candidate))
10782    })?;
10783    for segment in segments {
10784        current = index.module_target(&current, segment)?;
10785    }
10786    Some(current)
10787}
10788
10789fn rust_file_for_src_prefix<I: ResolverIndex>(
10790    index: &I,
10791    src_prefix: &str,
10792    segments: &[String],
10793) -> Option<String> {
10794    let candidate = if segments.is_empty() {
10795        [src_prefix, "lib.rs"].join("/")
10796    } else {
10797        format!("{}/{}.rs", src_prefix, segments.join("/"))
10798    };
10799    if index.contains_file(&candidate) {
10800        return Some(candidate);
10801    }
10802    if !segments.is_empty() {
10803        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10804        if index.contains_file(&mod_candidate) {
10805            return Some(mod_candidate);
10806        }
10807    }
10808    None
10809}
10810
10811fn rust_src_prefix(rel_path: &str) -> String {
10812    rel_path
10813        .split_once("/src/")
10814        .map(|(prefix, _)| format!("{prefix}/src"))
10815        .unwrap_or_else(|| "src".to_string())
10816}
10817
10818fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10819    let after_src = rel_path
10820        .split_once("/src/")
10821        .map(|(_, rest)| rest)
10822        .or_else(|| rel_path.strip_prefix("src/"))
10823        .unwrap_or(rel_path);
10824    if matches!(after_src, "lib.rs" | "main.rs") {
10825        return Vec::new();
10826    }
10827    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10828        return prefix.split('/').map(|item| item.to_string()).collect();
10829    }
10830    after_src
10831        .strip_suffix(".rs")
10832        .unwrap_or(after_src)
10833        .split('/')
10834        .map(|item| item.to_string())
10835        .collect()
10836}
10837
10838fn resolve_local_target<I: ResolverIndex>(
10839    _index: &I,
10840    caller_file: &str,
10841    full_ref: &str,
10842    short_name: &str,
10843    caller_data: &FileCallData,
10844) -> Option<(String, String, String)> {
10845    if !callgraph::is_bare_callee(full_ref, short_name) {
10846        return None;
10847    }
10848    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10849        .ok()
10850        .map(|symbol| {
10851            (
10852                "resolved_local".to_string(),
10853                caller_file.to_string(),
10854                symbol,
10855            )
10856        })
10857}
10858
10859impl<'a> ProjectIndex<'a> {
10860    fn from_parts(
10861        project_root: &Path,
10862        files: HashMap<String, DbFileIndex>,
10863        caller_data: HashMap<String, &'a FileCallData>,
10864        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10865        facts: Rc<dyn ProjectFacts + 'a>,
10866    ) -> Self {
10867        Self {
10868            facts,
10869            unbound_non_utf8_paths: Vec::new(),
10870            project_root: project_root.to_path_buf(),
10871            files,
10872            caller_data,
10873            workspace_crate_prefixes,
10874            rust_crate_roots: callgraph::RustCrateRootMemo::default(),
10875        }
10876    }
10877
10878    fn from_db_and_callers(
10879        tx: &Transaction<'_>,
10880        project_root: &Path,
10881        caller_extracts: &'a HashMap<String, FileExtract>,
10882        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10883    ) -> Result<Self> {
10884        // Incremental refreshes get a fresh snapshot memo so a watcher rewrite can
10885        // never observe declarations retained by an earlier refresh generation.
10886        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
10887        let disk = DiskFacts::new(project_root);
10888        let facts = FactPaths {
10889            root: project_root,
10890            facts: &disk,
10891        };
10892        let mut files = load_db_file_indexes(tx, project_root, &module_resolution_memo, &facts)?;
10893        let mut caller_data = HashMap::new();
10894        for (rel_path, extract) in caller_extracts {
10895            files.insert(
10896                rel_path.clone(),
10897                DbFileIndex::from_extract(
10898                    project_root,
10899                    extract,
10900                    &FactPaths {
10901                        root: project_root,
10902                        facts: &DiskFacts::new(project_root),
10903                    },
10904                ),
10905            );
10906            caller_data.insert(rel_path.clone(), &extract.data);
10907        }
10908        Ok(Self::from_parts(
10909            project_root,
10910            files,
10911            caller_data,
10912            workspace_crate_prefixes,
10913            Rc::new(DiskFacts::new(project_root)),
10914        ))
10915    }
10916
10917    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10918        self.files.get(rel_path).and_then(|file| file.lang)
10919    }
10920
10921    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10922        self.files
10923            .get(caller_file)
10924            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10925    }
10926
10927    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10928        self.files
10929            .get(rel_path)
10930            .map(|file| file.reexports.as_slice())
10931            .unwrap_or(&[])
10932    }
10933
10934    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10935        self.files.get(rel_path).and_then(|file| {
10936            file.node_by_scoped
10937                .get(symbol)
10938                .cloned()
10939                .or_else(|| file.node_by_bare.get(symbol).cloned())
10940        })
10941    }
10942
10943    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10944        self.files
10945            .get(rel_path)
10946            .and_then(|file| file.node_kind_by_id.get(node_id))
10947            .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
10948    }
10949}
10950
10951impl DbFileIndex {
10952    fn from_extract(project_root: &Path, extract: &FileExtract, facts: &FactPaths<'_>) -> Self {
10953        let mut node_by_scoped = HashMap::new();
10954        let mut node_by_bare = HashMap::new();
10955        for node in &extract.nodes {
10956            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10957            node_by_bare
10958                .entry(node.name.clone())
10959                .or_insert(node.id.clone());
10960        }
10961        let node_kind_by_id = extract
10962            .nodes
10963            .iter()
10964            .map(|node| (node.id.clone(), node.kind.clone()))
10965            .collect();
10966        let mut export_aliases = HashMap::new();
10967        for raw_ref in &extract.raw_refs {
10968            if raw_ref.kind == "export_alias" {
10969                if let (Some(exported), Some(source_symbol)) =
10970                    (&raw_ref.local_name, &raw_ref.requested_name)
10971                {
10972                    export_aliases.insert(exported.clone(), source_symbol.clone());
10973                }
10974            }
10975        }
10976        let mut module_targets = HashMap::new();
10977        let mut declared_module_targets = HashMap::new();
10978        let mut reexports = Vec::new();
10979        for raw_ref in &extract.raw_refs {
10980            if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10981                continue;
10982            }
10983            let Some(module_path) = &raw_ref.module_path else {
10984                continue;
10985            };
10986            let target_file =
10987                module_target_from_dependencies(project_root, &raw_ref.dependencies, facts);
10988            module_targets
10989                .entry(module_path.clone())
10990                .or_insert_with(|| target_file.clone());
10991            if raw_ref.kind == "module" {
10992                declared_module_targets
10993                    .entry(module_path.clone())
10994                    .or_insert_with(|| target_file.clone());
10995            }
10996            if raw_ref.kind == "reexport" {
10997                reexports.push(reexport_index_from_raw(raw_ref, target_file));
10998            }
10999        }
11000        Self {
11001            lang: Some(extract.lang),
11002            exports: extract.data.exported_symbols.iter().cloned().collect(),
11003            default_export: extract.data.default_export_symbol.clone(),
11004            export_aliases,
11005            node_by_scoped,
11006            node_by_bare,
11007            node_kind_by_id,
11008            module_targets,
11009            declared_module_targets,
11010            reexports,
11011        }
11012    }
11013}
11014
11015fn load_db_file_indexes(
11016    tx: &Transaction<'_>,
11017    project_root: &Path,
11018    module_resolution_memo: &callgraph::ModuleResolutionMemo,
11019    facts: &FactPaths<'_>,
11020) -> Result<HashMap<String, DbFileIndex>> {
11021    let mut files = HashMap::new();
11022    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
11023    let rows = stmt.query_map([], |row| {
11024        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11025    })?;
11026    for row in rows {
11027        let (rel_path, lang) = row?;
11028        files.insert(
11029            rel_path.clone(),
11030            DbFileIndex {
11031                lang: lang_from_label(&lang),
11032                exports: HashSet::new(),
11033                default_export: None,
11034                export_aliases: HashMap::new(),
11035                node_by_scoped: HashMap::new(),
11036                node_by_bare: HashMap::new(),
11037                node_kind_by_id: HashMap::new(),
11038                module_targets: HashMap::new(),
11039                declared_module_targets: HashMap::new(),
11040                reexports: Vec::new(),
11041            },
11042        );
11043    }
11044
11045    let mut node_stmt = tx.prepare(
11046        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
11047    )?;
11048    let nodes = node_stmt.query_map([], |row| {
11049        Ok((
11050            row.get::<_, String>(0)?,
11051            row.get::<_, String>(1)?,
11052            row.get::<_, String>(2)?,
11053            row.get::<_, String>(3)?,
11054            row.get::<_, String>(4)?,
11055            row.get::<_, i64>(5)? != 0,
11056            row.get::<_, i64>(6)? != 0,
11057        ))
11058    })?;
11059    for row in nodes {
11060        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
11061        let file = files
11062            .entry(file_path.clone())
11063            .or_insert_with(|| DbFileIndex {
11064                lang: None,
11065                exports: HashSet::new(),
11066                default_export: None,
11067                export_aliases: HashMap::new(),
11068                node_by_scoped: HashMap::new(),
11069                node_by_bare: HashMap::new(),
11070                node_kind_by_id: HashMap::new(),
11071                module_targets: HashMap::new(),
11072                declared_module_targets: HashMap::new(),
11073                reexports: Vec::new(),
11074            });
11075        if exported {
11076            file.exports.insert(name.clone());
11077            file.exports.insert(scoped_name.clone());
11078        }
11079        if is_default_export {
11080            file.default_export = Some(scoped_name.clone());
11081        }
11082        file.node_by_scoped.insert(scoped_name, id.clone());
11083        file.node_by_bare.entry(name).or_insert(id.clone());
11084        file.node_kind_by_id.insert(id, kind);
11085    }
11086    let file_keys: HashSet<String> = files.keys().cloned().collect();
11087    // Persisted caller extracts supply import targets. Only reexports from other
11088    // files need dependency reconstruction, and their caller dependencies are
11089    // loaded once instead of issuing repeated SQLite queries per reference.
11090    let dependencies_by_file = load_file_dependencies_index(tx)?;
11091    let mut ref_stmt = tx.prepare(
11092        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
11093             FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
11094    )?;
11095    let ref_rows = ref_stmt.query_map([], |row| {
11096        Ok((
11097            row.get::<_, String>(0)?,
11098            row.get::<_, String>(1)?,
11099            row.get::<_, String>(2)?,
11100            row.get::<_, Option<String>>(3)?,
11101            row.get::<_, Option<String>>(4)?,
11102            row.get::<_, i64>(5)? != 0,
11103            row.get::<_, Option<String>>(6)?,
11104            row.get::<_, Option<String>>(7)?,
11105        ))
11106    })?;
11107    for row in ref_rows {
11108        let (
11109            ref_id,
11110            caller_file,
11111            kind,
11112            module_path,
11113            full_ref,
11114            wildcard,
11115            local_name,
11116            requested_name,
11117        ) = row?;
11118        if kind == "export_alias" {
11119            if let (Some(exported), Some(source_symbol), Some(file)) =
11120                (local_name, requested_name, files.get_mut(&caller_file))
11121            {
11122                file.export_aliases.insert(exported, source_symbol);
11123            }
11124            continue;
11125        }
11126        let Some(module_path) = module_path else {
11127            continue;
11128        };
11129        let file_deps = dependencies_by_file
11130            .get(&caller_file)
11131            .cloned()
11132            .unwrap_or_default();
11133        let deps = stored_dependencies_for_module(
11134            project_root,
11135            &caller_file,
11136            &module_path,
11137            &file_deps,
11138            &file_keys,
11139            facts,
11140        );
11141        let target_file = if kind == "module" {
11142            rust_declared_module_target(
11143                project_root,
11144                &caller_file,
11145                &module_path,
11146                module_resolution_memo,
11147                facts,
11148            )
11149        } else {
11150            deps.iter().find(|dep| file_keys.contains(*dep)).map(|dep| {
11151                relative_path(
11152                    project_root,
11153                    &facts
11154                        .canonical(&project_root.join(dep))
11155                        .unwrap_or_else(|| project_root.join(dep)),
11156                )
11157            })
11158        };
11159        if let Some(file) = files.get_mut(&caller_file) {
11160            file.module_targets
11161                .entry(module_path.clone())
11162                .or_insert_with(|| target_file.clone());
11163            if kind == "module" {
11164                file.declared_module_targets
11165                    .entry(module_path.clone())
11166                    .or_insert_with(|| target_file.clone());
11167            }
11168            if kind == "reexport" {
11169                let raw = RawRef {
11170                    ref_id,
11171                    caller_node: None,
11172                    caller_symbol: None,
11173                    caller_file,
11174                    kind,
11175                    short_name: None,
11176                    full_ref,
11177                    module_path: Some(module_path),
11178                    import_kind: Some("reexport".to_string()),
11179                    local_name: None,
11180                    requested_name: None,
11181                    namespace_alias: None,
11182                    wildcard,
11183                    line: 0,
11184                    byte_start: 0,
11185                    byte_end: 0,
11186                    dependencies: deps,
11187                };
11188                file.reexports
11189                    .push(reexport_index_from_raw(&raw, target_file));
11190            }
11191        }
11192    }
11193
11194    Ok(files)
11195}
11196
11197fn stored_dependencies_for_module(
11198    project_root: &Path,
11199    caller_file: &str,
11200    module_path: &str,
11201    caller_dependencies: &BTreeSet<String>,
11202    indexed_files: &HashSet<String>,
11203    facts: &FactPaths<'_>,
11204) -> BTreeSet<String> {
11205    let caller_path = project_root.join(caller_file);
11206    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path, facts);
11207    if module_path.starts_with('.') {
11208        let caller_dir = caller_path.parent().unwrap_or(project_root);
11209        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
11210            let normalized = if facts.is_file(&candidate) {
11211                facts.canonical(&candidate).unwrap_or(candidate.clone())
11212            } else {
11213                candidate
11214            };
11215            candidates.insert(relative_path(project_root, &normalized));
11216        }
11217    }
11218    let exact = candidates
11219        .intersection(caller_dependencies)
11220        .filter(|dependency| indexed_files.contains(*dependency))
11221        .cloned()
11222        .collect::<BTreeSet<_>>();
11223    if !exact.is_empty() || module_path.starts_with('.') {
11224        return exact;
11225    }
11226
11227    let module_path = rust_module_path_without_alias_or_use_list(module_path)
11228        .trim_matches(|character| matches!(character, '\'' | '"'));
11229    let package_name = module_path
11230        .split('/')
11231        .next_back()
11232        .unwrap_or(module_path)
11233        .replace('_', "-");
11234    let matched = caller_dependencies
11235        .iter()
11236        .filter(|dependency| indexed_files.contains(*dependency))
11237        .filter(|dependency| {
11238            dependency.as_str() == module_path
11239                || dependency.ends_with(&format!("/{module_path}"))
11240                || Path::new(dependency).components().any(|component| {
11241                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
11242                })
11243        })
11244        .cloned()
11245        .collect::<BTreeSet<_>>();
11246    if matched.len() == 1 {
11247        matched
11248    } else {
11249        BTreeSet::new()
11250    }
11251}
11252
11253fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
11254    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
11255    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
11256    let rows = stmt.query_map([], |row| {
11257        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11258    })?;
11259    for row in rows {
11260        let (file_path, dependency) = row?;
11261        by_file.entry(file_path).or_default().insert(dependency);
11262    }
11263    Ok(by_file)
11264}
11265
11266struct ColdBuildInsertStatements<'stmt> {
11267    file: Statement<'stmt>,
11268    node: Statement<'stmt>,
11269    file_dependency: Statement<'stmt>,
11270    dispatch_hint: Statement<'stmt>,
11271    backend_state: Statement<'stmt>,
11272    reference: Statement<'stmt>,
11273    staging_ref_context: Statement<'stmt>,
11274    edge: Statement<'stmt>,
11275}
11276
11277impl<'stmt> ColdBuildInsertStatements<'stmt> {
11278    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
11279        Ok(Self {
11280            file: tx.prepare(
11281                "INSERT OR REPLACE INTO files(
11282                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11283                    is_public_api, surface_fingerprint, indexed_at
11284                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11285            )?,
11286            node: tx.prepare(
11287                "INSERT OR REPLACE INTO nodes(
11288                    id, file_path, name, scoped_name, kind, start_line, start_col,
11289                    end_line, end_col, range_ordinal, signature, exported,
11290                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
11291                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11292            )?,
11293            file_dependency: tx.prepare(
11294                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11295            )?,
11296            dispatch_hint: tx.prepare(
11297                "INSERT OR REPLACE INTO dispatch_hints(
11298                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11299                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11300            )?,
11301            backend_state: tx.prepare(
11302                "INSERT OR REPLACE INTO backend_file_state(
11303                    backend, workspace_root, file_path, content_hash, status, updated_at
11304                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11305            )?,
11306            reference: tx.prepare(
11307                "INSERT OR REPLACE INTO refs(
11308                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11309                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11310                    byte_start, byte_end, status, target_node, target_file, target_symbol,
11311                    provenance
11312                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11313            )?,
11314            staging_ref_context: tx.prepare(
11315                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
11316            )?,
11317            edge: tx.prepare(
11318                "INSERT OR REPLACE INTO edges(
11319                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11320                    kind, line, provenance
11321                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11322            )?,
11323        })
11324    }
11325}
11326
11327fn insert_file_extract_prepared(
11328    statements: &mut ColdBuildInsertStatements<'_>,
11329    workspace_root: &str,
11330    extract: &FileExtract,
11331) -> Result<()> {
11332    statements.file.execute(params![
11333        extract.rel_path,
11334        hash_to_hex(extract.freshness.content_hash),
11335        system_time_to_ns(extract.freshness.mtime),
11336        extract.freshness.size as i64,
11337        lang_label(extract.lang),
11338        extract.surface_fingerprint,
11339        unix_seconds_now(),
11340    ])?;
11341    for node in &extract.nodes {
11342        statements.node.execute(params![
11343            node.id,
11344            node.file_path,
11345            node.name,
11346            node.scoped_name,
11347            node.kind,
11348            node.range.start_line as i64,
11349            node.range.start_col as i64,
11350            node.range.end_line as i64,
11351            node.range.end_col as i64,
11352            node.range_ordinal as i64,
11353            node.signature,
11354            bool_int(node.exported),
11355            bool_int(node.is_default_export),
11356            bool_int(node.is_type_like),
11357            bool_int(node.is_callgraph_entry_point),
11358            PROVENANCE_TREESITTER,
11359        ])?;
11360    }
11361
11362    let mut dependencies = BTreeSet::new();
11363    for raw_ref in &extract.raw_refs {
11364        dependencies.extend(raw_ref.dependencies.iter().cloned());
11365    }
11366    for dep_file in &dependencies {
11367        statements
11368            .file_dependency
11369            .execute(params![extract.rel_path, dep_file])?;
11370    }
11371
11372    for hint in &extract.dispatch_hints {
11373        statements.dispatch_hint.execute(params![
11374            hint.id,
11375            hint.method_name,
11376            hint.caller_node,
11377            hint.file,
11378            hint.line as i64,
11379            hint.byte_start as i64,
11380            hint.byte_end as i64,
11381            PROVENANCE_TREESITTER,
11382        ])?;
11383    }
11384    insert_backend_state_prepared(
11385        &mut statements.backend_state,
11386        workspace_root,
11387        &extract.rel_path,
11388        Some(&extract.freshness.content_hash),
11389        "fresh",
11390    )?;
11391    Ok(())
11392}
11393
11394fn insert_backend_state_prepared(
11395    stmt: &mut Statement<'_>,
11396    workspace_root: &str,
11397    rel_path: &str,
11398    content_hash: Option<&blake3::Hash>,
11399    status: &str,
11400) -> Result<()> {
11401    let hash = content_hash
11402        .map(|hash| hash_to_hex(*hash))
11403        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11404    stmt.execute(params![
11405        BACKEND_TREESITTER,
11406        workspace_root,
11407        rel_path,
11408        hash,
11409        status,
11410        unix_seconds_now(),
11411    ])?;
11412    Ok(())
11413}
11414
11415fn insert_staged_ref_prepared(
11416    statements: &mut ColdBuildInsertStatements<'_>,
11417    raw: &RawRef,
11418) -> Result<()> {
11419    statements.reference.execute(params![
11420        raw.ref_id,
11421        raw.caller_node,
11422        raw.caller_file,
11423        raw.kind,
11424        raw.short_name,
11425        raw.full_ref,
11426        raw.module_path,
11427        raw.import_kind,
11428        raw.local_name,
11429        raw.requested_name,
11430        raw.namespace_alias,
11431        bool_int(raw.wildcard),
11432        raw.line as i64,
11433        raw.byte_start as i64,
11434        raw.byte_end as i64,
11435        "staged",
11436        Option::<String>::None,
11437        Option::<String>::None,
11438        Option::<String>::None,
11439        ref_provenance(raw),
11440    ])?;
11441    statements
11442        .staging_ref_context
11443        .execute(params![raw.ref_id, raw.caller_symbol])?;
11444    Ok(())
11445}
11446
11447fn insert_resolved_ref_prepared(
11448    statements: &mut ColdBuildInsertStatements<'_>,
11449    resolved: &ResolvedRef,
11450) -> Result<()> {
11451    let raw = &resolved.raw;
11452    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11453    statements.reference.execute(params![
11454        raw.ref_id,
11455        raw.caller_node,
11456        raw.caller_file,
11457        raw.kind,
11458        raw.short_name,
11459        raw.full_ref,
11460        raw.module_path,
11461        raw.import_kind,
11462        raw.local_name,
11463        raw.requested_name,
11464        raw.namespace_alias,
11465        bool_int(raw.wildcard),
11466        raw.line as i64,
11467        raw.byte_start as i64,
11468        raw.byte_end as i64,
11469        resolved.status,
11470        resolved.target_node,
11471        resolved.target_file,
11472        resolved.target_symbol,
11473        ref_provenance(raw),
11474    ])?;
11475    if let Some(edge) = &resolved.edge {
11476        statements.edge.execute(params![
11477            edge.edge_id,
11478            raw.ref_id,
11479            edge.source_node,
11480            edge.target_node,
11481            edge.target_file,
11482            edge.target_symbol,
11483            edge.kind,
11484            edge.line as i64,
11485            ref_provenance(raw),
11486        ])?;
11487    }
11488    Ok(())
11489}
11490
11491#[cfg(test)]
11492fn insert_file_extract(
11493    tx: &Transaction<'_>,
11494    project_root: &Path,
11495    extract: &FileExtract,
11496) -> Result<()> {
11497    tx.execute(
11498        "INSERT OR REPLACE INTO files(
11499            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11500            is_public_api, surface_fingerprint, indexed_at
11501        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11502        params![
11503            extract.rel_path,
11504            hash_to_hex(extract.freshness.content_hash),
11505            system_time_to_ns(extract.freshness.mtime),
11506            extract.freshness.size as i64,
11507            lang_label(extract.lang),
11508            extract.surface_fingerprint,
11509            unix_seconds_now(),
11510        ],
11511    )?;
11512    for node in &extract.nodes {
11513        tx.execute(
11514            "INSERT OR REPLACE INTO nodes(
11515                id, file_path, name, scoped_name, kind, start_line, start_col,
11516                end_line, end_col, range_ordinal, signature, exported,
11517                is_default_export, is_type_like, is_callgraph_entry_point, provenance
11518            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11519            params![
11520                node.id,
11521                node.file_path,
11522                node.name,
11523                node.scoped_name,
11524                node.kind,
11525                node.range.start_line as i64,
11526                node.range.start_col as i64,
11527                node.range.end_line as i64,
11528                node.range.end_col as i64,
11529                node.range_ordinal as i64,
11530                node.signature,
11531                bool_int(node.exported),
11532                bool_int(node.is_default_export),
11533                bool_int(node.is_type_like),
11534                bool_int(node.is_callgraph_entry_point),
11535                PROVENANCE_TREESITTER,
11536            ],
11537        )?;
11538    }
11539    let mut dependencies = BTreeSet::new();
11540    for raw_ref in &extract.raw_refs {
11541        dependencies.extend(raw_ref.dependencies.iter().cloned());
11542    }
11543    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
11544
11545    for hint in &extract.dispatch_hints {
11546        tx.execute(
11547            "INSERT OR REPLACE INTO dispatch_hints(
11548                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11549            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11550            params![
11551                hint.id,
11552                hint.method_name,
11553                hint.caller_node,
11554                hint.file,
11555                hint.line as i64,
11556                hint.byte_start as i64,
11557                hint.byte_end as i64,
11558                PROVENANCE_TREESITTER,
11559            ],
11560        )?;
11561    }
11562    mark_backend_state(
11563        tx,
11564        project_root,
11565        &extract.rel_path,
11566        Some(&extract.freshness.content_hash),
11567        "fresh",
11568    )?;
11569    Ok(())
11570}
11571
11572#[cfg(test)]
11573fn insert_file_dependencies(
11574    tx: &Transaction<'_>,
11575    file_path: &str,
11576    dependencies: &BTreeSet<String>,
11577) -> Result<()> {
11578    for dep_file in dependencies {
11579        tx.execute(
11580            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11581            params![file_path, dep_file],
11582        )?;
11583    }
11584    Ok(())
11585}
11586
11587fn ref_provenance(raw: &RawRef) -> &'static str {
11588    if raw.kind == "value_ref" {
11589        PROVENANCE_VALUE_REF
11590    } else {
11591        PROVENANCE_TREESITTER
11592    }
11593}
11594
11595#[cfg(test)]
11596fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
11597    let raw = &resolved.raw;
11598    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11599    tx.execute(
11600        "INSERT OR REPLACE INTO refs(
11601            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11602            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11603            byte_start, byte_end, status, target_node, target_file, target_symbol,
11604            provenance
11605        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11606        params![
11607            raw.ref_id,
11608            raw.caller_node,
11609            raw.caller_file,
11610            raw.kind,
11611            raw.short_name,
11612            raw.full_ref,
11613            raw.module_path,
11614            raw.import_kind,
11615            raw.local_name,
11616            raw.requested_name,
11617            raw.namespace_alias,
11618            bool_int(raw.wildcard),
11619            raw.line as i64,
11620            raw.byte_start as i64,
11621            raw.byte_end as i64,
11622            resolved.status,
11623            resolved.target_node,
11624            resolved.target_file,
11625            resolved.target_symbol,
11626            ref_provenance(raw),
11627        ],
11628    )?;
11629    if let Some(edge) = &resolved.edge {
11630        tx.execute(
11631            "INSERT OR REPLACE INTO edges(
11632                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11633                kind, line, provenance
11634            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11635            params![
11636                edge.edge_id,
11637                raw.ref_id,
11638                edge.source_node,
11639                edge.target_node,
11640                edge.target_file,
11641                edge.target_symbol,
11642                edge.kind,
11643                edge.line as i64,
11644                ref_provenance(raw),
11645            ],
11646        )?;
11647    }
11648    Ok(())
11649}
11650
11651fn insert_method_dispatch_edges(
11652    tx: &Transaction<'_>,
11653    project_root: &Path,
11654    caller_files: Option<&BTreeSet<String>>,
11655) -> Result<usize> {
11656    let references = load_name_match_refs(tx, caller_files)?;
11657    if references.is_empty() {
11658        return Ok(0);
11659    }
11660
11661    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
11662    let mut source_cache: DispatchSourceCache = HashMap::new();
11663    let mut inserted = 0usize;
11664    for reference in references {
11665        let key = (reference.method_name.clone(), reference.lang.clone());
11666        let candidates = match candidates_by_name.entry(key) {
11667            Entry::Occupied(entry) => entry.into_mut(),
11668            Entry::Vacant(entry) => {
11669                let candidates =
11670                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11671                entry.insert(candidates)
11672            }
11673        };
11674
11675        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11676            ReceiverTypeInference::Known(receiver_type) => {
11677                let Some(candidate) =
11678                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11679                else {
11680                    continue;
11681                };
11682                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11683                inserted += 1;
11684                continue;
11685            }
11686            ReceiverTypeInference::RustDirectSelfField {
11687                receiver_type,
11688                declaration_file,
11689                module_scope,
11690            } => {
11691                let Some(candidate) = select_rust_direct_self_field_candidate(
11692                    project_root,
11693                    &reference,
11694                    candidates.as_slice(),
11695                    &receiver_type,
11696                    &declaration_file,
11697                    &module_scope,
11698                    &mut source_cache,
11699                ) else {
11700                    continue;
11701                };
11702                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11703                inserted += 1;
11704                continue;
11705            }
11706            ReceiverTypeInference::KnownButUnresolved => continue,
11707            ReceiverTypeInference::Unknown => {}
11708        }
11709
11710        if method_name_match_denylisted(&reference.method_name) {
11711            continue;
11712        }
11713
11714        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11715            continue;
11716        };
11717        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11718        inserted += 1;
11719    }
11720    Ok(inserted)
11721}
11722
11723fn insert_method_dispatch_edges_chunked(
11724    tx: &Transaction<'_>,
11725    project_root: &Path,
11726    chunk_size: usize,
11727) -> Result<usize> {
11728    let total_files = query_count(
11729        tx,
11730        "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11731    )? as usize;
11732    let mut completed_files = 0usize;
11733    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11734    let mut inserted = 0usize;
11735    let mut after_file = String::new();
11736    loop {
11737        let caller_files = {
11738            let mut statement = tx.prepare(
11739                "SELECT DISTINCT caller_file
11740                 FROM refs
11741                 WHERE caller_file > ?1
11742                 ORDER BY caller_file
11743                 LIMIT ?2",
11744            )?;
11745            let rows = statement
11746                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11747                    row.get::<_, String>(0)
11748                })?;
11749            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11750        };
11751        let Some(last_file) = caller_files.last().cloned() else {
11752            break;
11753        };
11754        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11755        after_file = last_file;
11756        completed_files = completed_files
11757            .saturating_add(caller_files.len())
11758            .min(total_files);
11759        ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11760    }
11761    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11762    Ok(inserted)
11763}
11764
11765fn insert_method_dispatch_edge(
11766    tx: &Transaction<'_>,
11767    reference: &NameMatchRef,
11768    candidate: &NameMatchCandidate,
11769    provenance: &str,
11770) -> Result<()> {
11771    tx.execute(
11772        "INSERT OR REPLACE INTO edges(
11773            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11774            kind, line, provenance
11775        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11776        params![
11777            ref_id(&[&reference.ref_id, provenance, "edge"]),
11778            &reference.ref_id,
11779            &reference.caller_node,
11780            &candidate.node_id,
11781            &candidate.file_path,
11782            &candidate.scoped_name,
11783            reference.line as i64,
11784            provenance,
11785        ],
11786    )?;
11787    Ok(())
11788}
11789
11790fn delete_method_dispatch_edges_for_callers(
11791    tx: &Transaction<'_>,
11792    caller_files: &BTreeSet<String>,
11793) -> Result<()> {
11794    if caller_files.is_empty() {
11795        return Ok(());
11796    }
11797
11798    let mut stmt = tx.prepare(
11799        "DELETE FROM edges
11800         WHERE provenance IN (?1, ?2)
11801           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11802    )?;
11803    for caller_file in caller_files {
11804        stmt.execute(params![
11805            PROVENANCE_NAME_MATCH,
11806            PROVENANCE_TYPE_MATCH,
11807            caller_file
11808        ])?;
11809    }
11810    Ok(())
11811}
11812
11813fn load_name_match_refs(
11814    tx: &Transaction<'_>,
11815    caller_files: Option<&BTreeSet<String>>,
11816) -> Result<Vec<NameMatchRef>> {
11817    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11818                           n.signature, r.short_name, r.full_ref, r.line, f.lang
11819                    FROM refs r
11820                    JOIN files f ON f.path = r.caller_file
11821                    JOIN nodes n ON n.id = r.caller_node
11822                    WHERE r.kind = 'call'
11823                      AND r.status = 'unresolved'
11824                      AND r.caller_node IS NOT NULL
11825                      AND r.full_ref IS NOT NULL
11826                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11827                      AND NOT EXISTS (
11828                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11829                      )";
11830    let mut references = Vec::new();
11831
11832    if let Some(caller_files) = caller_files {
11833        if caller_files.is_empty() {
11834            return Ok(references);
11835        }
11836        let sql = format!(
11837            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11838        );
11839        let mut stmt = tx.prepare(&sql)?;
11840        for caller_file in caller_files {
11841            let rows = stmt.query_map(params![caller_file], |row| {
11842                Ok((
11843                    row.get::<_, String>(0)?,
11844                    row.get::<_, Option<String>>(1)?,
11845                    row.get::<_, String>(2)?,
11846                    row.get::<_, String>(3)?,
11847                    row.get::<_, Option<String>>(4)?,
11848                    row.get::<_, Option<String>>(5)?,
11849                    row.get::<_, Option<String>>(6)?,
11850                    row.get::<_, i64>(7)?,
11851                    row.get::<_, String>(8)?,
11852                ))
11853            })?;
11854            for row in rows {
11855                let (
11856                    ref_id,
11857                    caller_node,
11858                    caller_file,
11859                    caller_symbol,
11860                    caller_signature,
11861                    short_name,
11862                    full_ref,
11863                    line,
11864                    lang,
11865                ) = row?;
11866                if let Some(reference) = name_match_ref_from_parts(
11867                    ref_id,
11868                    caller_node,
11869                    caller_file,
11870                    caller_symbol,
11871                    caller_signature,
11872                    short_name,
11873                    full_ref,
11874                    line,
11875                    lang,
11876                ) {
11877                    references.push(reference);
11878                }
11879            }
11880        }
11881        return Ok(references);
11882    }
11883
11884    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11885    let mut stmt = tx.prepare(&sql)?;
11886    let rows = stmt.query_map([], |row| {
11887        Ok((
11888            row.get::<_, String>(0)?,
11889            row.get::<_, Option<String>>(1)?,
11890            row.get::<_, String>(2)?,
11891            row.get::<_, String>(3)?,
11892            row.get::<_, Option<String>>(4)?,
11893            row.get::<_, Option<String>>(5)?,
11894            row.get::<_, Option<String>>(6)?,
11895            row.get::<_, i64>(7)?,
11896            row.get::<_, String>(8)?,
11897        ))
11898    })?;
11899    for row in rows {
11900        let (
11901            ref_id,
11902            caller_node,
11903            caller_file,
11904            caller_symbol,
11905            caller_signature,
11906            short_name,
11907            full_ref,
11908            line,
11909            lang,
11910        ) = row?;
11911        if let Some(reference) = name_match_ref_from_parts(
11912            ref_id,
11913            caller_node,
11914            caller_file,
11915            caller_symbol,
11916            caller_signature,
11917            short_name,
11918            full_ref,
11919            line,
11920            lang,
11921        ) {
11922            references.push(reference);
11923        }
11924    }
11925    Ok(references)
11926}
11927
11928#[allow(clippy::too_many_arguments)]
11929fn name_match_ref_from_parts(
11930    ref_id: String,
11931    caller_node: Option<String>,
11932    caller_file: String,
11933    caller_symbol: String,
11934    caller_signature: Option<String>,
11935    short_name: Option<String>,
11936    full_ref: Option<String>,
11937    line: i64,
11938    lang: String,
11939) -> Option<NameMatchRef> {
11940    let caller_node = caller_node?;
11941    let full_ref = full_ref?;
11942    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11943    let method_name = if member.is_empty() {
11944        short_name.as_deref()?.to_string()
11945    } else {
11946        member
11947    };
11948    Some(NameMatchRef {
11949        ref_id,
11950        caller_node,
11951        caller_file,
11952        caller_symbol,
11953        caller_signature,
11954        receiver_expression,
11955        receiver,
11956        method_name,
11957        colon_dispatch,
11958        line: line.max(0) as u32,
11959        lang,
11960    })
11961}
11962
11963fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11964    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11965    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11966    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11967    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11968        .into_iter()
11969        .flatten()
11970        .max_by_key(|(index, _, _)| *index)?;
11971    if delimiter == 0 {
11972        return None;
11973    }
11974    let member_start = delimiter + delimiter_len;
11975    if member_start >= full_ref.len() {
11976        return None;
11977    }
11978    let receiver_expression = full_ref[..delimiter].trim();
11979    let receiver = last_name_segment(receiver_expression).trim();
11980    let member = &full_ref[member_start..];
11981    if receiver.is_empty() || member.is_empty() {
11982        return None;
11983    }
11984    Some((
11985        receiver_expression.to_string(),
11986        receiver.to_string(),
11987        member.to_string(),
11988        colon_dispatch,
11989    ))
11990}
11991
11992fn last_name_segment(value: &str) -> &str {
11993    value
11994        .rsplit(['.', ':', '/', '\\', '-', '>'])
11995        .find(|segment| !segment.is_empty())
11996        .unwrap_or(value)
11997}
11998
11999fn load_name_match_candidates(
12000    tx: &Transaction<'_>,
12001    method_name: &str,
12002    lang: &str,
12003) -> Result<Vec<NameMatchCandidate>> {
12004    let mut stmt = tx.prepare(
12005        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
12006         FROM nodes n JOIN files f ON f.path = n.file_path
12007         WHERE n.name = ?1
12008           AND f.lang = ?2
12009           AND n.kind IN ('method', 'function', 'kernel')
12010         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
12011    )?;
12012    let rows = stmt.query_map(params![method_name, lang], |row| {
12013        Ok(NameMatchCandidate {
12014            node_id: row.get(0)?,
12015            file_path: row.get(1)?,
12016            scoped_name: row.get(2)?,
12017            kind: row.get(3)?,
12018            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
12019        })
12020    })?;
12021    rows.collect::<std::result::Result<Vec<_>, _>>()
12022        .map_err(Into::into)
12023}
12024
12025struct ParsedDispatchSource {
12026    source: String,
12027    tree: tree_sitter::Tree,
12028}
12029
12030type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
12031
12032#[derive(Debug, Clone, PartialEq, Eq)]
12033enum ReceiverTypeInference {
12034    Unknown,
12035    Known(String),
12036    RustDirectSelfField {
12037        receiver_type: String,
12038        declaration_file: String,
12039        module_scope: Vec<(usize, usize)>,
12040    },
12041    KnownButUnresolved,
12042}
12043
12044#[cfg(test)]
12045fn infer_receiver_type(
12046    project_root: &Path,
12047    reference: &NameMatchRef,
12048    source_cache: &mut DispatchSourceCache,
12049) -> Option<String> {
12050    match infer_receiver_type_state(project_root, reference, source_cache) {
12051        ReceiverTypeInference::Known(receiver_type)
12052        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
12053        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
12054    }
12055}
12056
12057fn infer_receiver_type_state(
12058    project_root: &Path,
12059    reference: &NameMatchRef,
12060    source_cache: &mut DispatchSourceCache,
12061) -> ReceiverTypeInference {
12062    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
12063    match reference.lang.as_str() {
12064        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
12065        "java" => {
12066            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
12067                .map(known)
12068                .unwrap_or(ReceiverTypeInference::Unknown)
12069        }
12070        "kotlin" => {
12071            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
12072                .map(known)
12073                .unwrap_or(ReceiverTypeInference::Unknown)
12074        }
12075        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
12076            .map(known)
12077            .unwrap_or(ReceiverTypeInference::Unknown),
12078        _ => ReceiverTypeInference::Unknown,
12079    }
12080}
12081
12082fn parse_dispatch_source(
12083    project_root: &Path,
12084    caller_file: &str,
12085    lang: LangId,
12086) -> Option<ParsedDispatchSource> {
12087    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
12088    let grammar = crate::parser::grammar_for(lang);
12089    let mut parser = tree_sitter::Parser::new();
12090    parser.set_language(&grammar).ok()?;
12091    let tree = parser.parse(&source, None)?;
12092    Some(ParsedDispatchSource { source, tree })
12093}
12094
12095fn parsed_dispatch_source<'a>(
12096    project_root: &Path,
12097    reference: &NameMatchRef,
12098    lang: LangId,
12099    source_cache: &'a mut DispatchSourceCache,
12100) -> Option<&'a ParsedDispatchSource> {
12101    parsed_dispatch_source_for_file(
12102        project_root,
12103        &reference.caller_file,
12104        &reference.lang,
12105        lang,
12106        source_cache,
12107    )
12108}
12109
12110fn parsed_dispatch_source_for_file<'a>(
12111    project_root: &Path,
12112    file_path: &str,
12113    lang_label: &str,
12114    lang: LangId,
12115    source_cache: &'a mut DispatchSourceCache,
12116) -> Option<&'a ParsedDispatchSource> {
12117    let key = (file_path.to_string(), lang_label.to_string());
12118    source_cache
12119        .entry(key)
12120        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
12121        .as_ref()
12122}
12123
12124fn infer_java_like_receiver_type(
12125    project_root: &Path,
12126    reference: &NameMatchRef,
12127    lang: LangId,
12128    source_cache: &mut DispatchSourceCache,
12129) -> Option<String> {
12130    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12131        return None;
12132    }
12133
12134    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
12135    let root = parsed.tree.root_node();
12136    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
12137
12138    let callable_scope = type_node
12139        .and_then(|node| {
12140            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
12141        })
12142        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
12143
12144    if let Some(callable_scope) = callable_scope {
12145        if let Some(receiver_type) = infer_java_like_local_receiver_type(
12146            callable_scope,
12147            &parsed.source,
12148            &reference.receiver,
12149            reference.line.max(1),
12150            lang,
12151        ) {
12152            return Some(receiver_type);
12153        }
12154    }
12155
12156    type_node.and_then(|node| {
12157        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
12158    })
12159}
12160
12161fn infer_cpp_receiver_type(
12162    project_root: &Path,
12163    reference: &NameMatchRef,
12164    source_cache: &mut DispatchSourceCache,
12165) -> Option<String> {
12166    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12167        return None;
12168    }
12169
12170    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
12171    let root = parsed.tree.root_node();
12172    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
12173    infer_cpp_receiver_type_from_scope(
12174        scope,
12175        &parsed.source,
12176        &reference.receiver,
12177        reference.line.max(1),
12178    )
12179}
12180
12181fn find_enclosing_java_like_type_node<'tree>(
12182    root: tree_sitter::Node<'tree>,
12183    source: &str,
12184    reference: &NameMatchRef,
12185    lang: LangId,
12186) -> Option<tree_sitter::Node<'tree>> {
12187    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
12188        .and_then(|name| simple_type_name(&name));
12189    let line = reference.line.max(1);
12190    let mut best = None;
12191    let mut stack = vec![root];
12192    while let Some(node) = stack.pop() {
12193        if !node_contains_line(node, line) {
12194            continue;
12195        }
12196        if is_java_like_type_kind(node.kind(), lang) {
12197            let name = declaration_name(node, source);
12198            if expected_type
12199                .as_deref()
12200                .is_none_or(|expected| name == Some(expected))
12201            {
12202                best = tighter_node(best, node);
12203            }
12204        }
12205        push_named_children(node, &mut stack);
12206    }
12207    best
12208}
12209
12210fn find_enclosing_java_like_callable_node<'tree>(
12211    root: tree_sitter::Node<'tree>,
12212    source: &str,
12213    reference: &NameMatchRef,
12214    lang: LangId,
12215) -> Option<tree_sitter::Node<'tree>> {
12216    let expected_name = reference.caller_symbol.rsplit("::").next();
12217    let line = reference.line.max(1);
12218    let mut best = None;
12219    let mut stack = vec![root];
12220    while let Some(node) = stack.pop() {
12221        if !node_contains_line(node, line) {
12222            continue;
12223        }
12224        if is_java_like_callable_kind(node.kind(), lang) {
12225            let name = declaration_name(node, source);
12226            if expected_name.is_none_or(|expected| name == Some(expected)) {
12227                best = tighter_node(best, node);
12228            }
12229        }
12230        push_named_children(node, &mut stack);
12231    }
12232    best
12233}
12234
12235fn find_enclosing_cpp_callable_node<'tree>(
12236    root: tree_sitter::Node<'tree>,
12237    _source: &str,
12238    reference: &NameMatchRef,
12239) -> Option<tree_sitter::Node<'tree>> {
12240    let line = reference.line.max(1);
12241    let mut best = None;
12242    let mut stack = vec![root];
12243    while let Some(node) = stack.pop() {
12244        if !node_contains_line(node, line) {
12245            continue;
12246        }
12247        if node.kind() == "function_definition" {
12248            best = tighter_node(best, node);
12249        }
12250        push_named_children(node, &mut stack);
12251    }
12252    best
12253}
12254
12255fn tighter_node<'tree>(
12256    current: Option<tree_sitter::Node<'tree>>,
12257    candidate: tree_sitter::Node<'tree>,
12258) -> Option<tree_sitter::Node<'tree>> {
12259    match current {
12260        Some(current)
12261            if current.start_byte() > candidate.start_byte()
12262                || (current.start_byte() == candidate.start_byte()
12263                    && current.end_byte() <= candidate.end_byte()) =>
12264        {
12265            Some(current)
12266        }
12267        _ => Some(candidate),
12268    }
12269}
12270
12271fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
12272    let start = node.start_position().row as u32 + 1;
12273    let end = node.end_position().row as u32 + 1;
12274    start <= line && line <= end
12275}
12276
12277fn push_named_children<'tree>(
12278    node: tree_sitter::Node<'tree>,
12279    stack: &mut Vec<tree_sitter::Node<'tree>>,
12280) {
12281    for index in 0..node.named_child_count() {
12282        if let Some(child) = node.named_child(index as u32) {
12283            stack.push(child);
12284        }
12285    }
12286}
12287
12288fn declaration_name<'source>(
12289    node: tree_sitter::Node<'_>,
12290    source: &'source str,
12291) -> Option<&'source str> {
12292    node.child_by_field_name("name")
12293        .map(|name| node_text(name, source))
12294        .or_else(|| {
12295            first_named_child_text(
12296                node,
12297                source,
12298                &["identifier", "type_identifier", "simple_identifier"],
12299            )
12300        })
12301}
12302
12303fn first_named_child_text<'source>(
12304    node: tree_sitter::Node<'_>,
12305    source: &'source str,
12306    kinds: &[&str],
12307) -> Option<&'source str> {
12308    for index in 0..node.named_child_count() {
12309        let child = node.named_child(index as u32)?;
12310        if kinds.contains(&child.kind()) {
12311            return Some(node_text(child, source));
12312        }
12313    }
12314    None
12315}
12316
12317fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
12318    &source[node.byte_range()]
12319}
12320
12321fn infer_java_like_field_receiver_type(
12322    type_node: tree_sitter::Node<'_>,
12323    source: &str,
12324    receiver: &str,
12325    lang: LangId,
12326) -> Option<String> {
12327    let mut stack = Vec::new();
12328    push_named_children(type_node, &mut stack);
12329    while let Some(node) = stack.pop() {
12330        if is_java_like_field_kind(node.kind(), lang) {
12331            if let Some(receiver_type) =
12332                extract_java_like_declared_type(node_text(node, source), receiver, lang)
12333            {
12334                return Some(receiver_type);
12335            }
12336        }
12337        if is_java_like_type_kind(node.kind(), lang)
12338            || is_java_like_callable_kind(node.kind(), lang)
12339        {
12340            continue;
12341        }
12342        push_named_children(node, &mut stack);
12343    }
12344    None
12345}
12346
12347fn infer_java_like_local_receiver_type(
12348    callable_node: tree_sitter::Node<'_>,
12349    source: &str,
12350    receiver: &str,
12351    call_line: u32,
12352    lang: LangId,
12353) -> Option<String> {
12354    let mut best: Option<(u32, String)> = None;
12355    let mut stack = Vec::new();
12356    push_named_children(callable_node, &mut stack);
12357    while let Some(node) = stack.pop() {
12358        let start_line = node.start_position().row as u32 + 1;
12359        if start_line > call_line {
12360            continue;
12361        }
12362        if is_java_like_local_kind(node.kind(), lang) {
12363            if let Some(receiver_type) =
12364                extract_java_like_declared_type(node_text(node, source), receiver, lang)
12365            {
12366                if best
12367                    .as_ref()
12368                    .is_none_or(|(best_line, _)| start_line >= *best_line)
12369                {
12370                    best = Some((start_line, receiver_type));
12371                }
12372            }
12373        }
12374        if is_java_like_type_kind(node.kind(), lang)
12375            || is_java_like_callable_kind(node.kind(), lang)
12376        {
12377            continue;
12378        }
12379        push_named_children(node, &mut stack);
12380    }
12381    best.map(|(_, receiver_type)| receiver_type)
12382}
12383
12384fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
12385    match lang {
12386        LangId::Java => matches!(
12387            kind,
12388            "class_declaration"
12389                | "interface_declaration"
12390                | "enum_declaration"
12391                | "record_declaration"
12392                | "annotation_type_declaration"
12393        ),
12394        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
12395        _ => false,
12396    }
12397}
12398
12399fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
12400    match lang {
12401        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
12402        LangId::Kotlin => kind == "function_declaration",
12403        _ => false,
12404    }
12405}
12406
12407fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
12408    match lang {
12409        LangId::Java => kind == "field_declaration",
12410        LangId::Kotlin => kind == "property_declaration",
12411        _ => false,
12412    }
12413}
12414
12415fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
12416    match lang {
12417        LangId::Java => kind == "local_variable_declaration",
12418        LangId::Kotlin => kind == "property_declaration",
12419        _ => false,
12420    }
12421}
12422
12423fn extract_java_like_declared_type(
12424    declaration: &str,
12425    receiver: &str,
12426    lang: LangId,
12427) -> Option<String> {
12428    match lang {
12429        LangId::Java => extract_java_declared_type(declaration, receiver),
12430        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
12431        _ => None,
12432    }
12433}
12434
12435fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12436    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12437    let after = declaration[receiver_start + receiver.len()..].trim_start();
12438    if after
12439        .chars()
12440        .next()
12441        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
12442    {
12443        return None;
12444    }
12445
12446    let before = declaration[..receiver_start].trim_end();
12447    if before.contains(',') {
12448        return None;
12449    }
12450    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
12451}
12452
12453fn strip_java_declaration_prefixes(mut value: &str) -> &str {
12454    loop {
12455        value = value.trim_start();
12456        if let Some(stripped) = strip_leading_java_annotation(value) {
12457            value = stripped;
12458            continue;
12459        }
12460        if let Some(stripped) = strip_leading_java_modifier(value) {
12461            value = stripped;
12462            continue;
12463        }
12464        return value.trim();
12465    }
12466}
12467
12468fn strip_leading_java_annotation(value: &str) -> Option<&str> {
12469    let value = value.trim_start();
12470    let mut chars = value.char_indices();
12471    let (_, first) = chars.next()?;
12472    if first != '@' {
12473        return None;
12474    }
12475    let mut end = first.len_utf8();
12476    for (index, ch) in chars {
12477        if !(is_code_ident_char(ch) || ch == '.') {
12478            end = index;
12479            break;
12480        }
12481        end = index + ch.len_utf8();
12482    }
12483    let rest = value[end..].trim_start();
12484    if let Some(stripped) = rest.strip_prefix('(') {
12485        let mut depth = 1usize;
12486        for (index, ch) in stripped.char_indices() {
12487            match ch {
12488                '(' => depth += 1,
12489                ')' => {
12490                    depth = depth.saturating_sub(1);
12491                    if depth == 0 {
12492                        return Some(stripped[index + ch.len_utf8()..].trim_start());
12493                    }
12494                }
12495                _ => {}
12496            }
12497        }
12498        return Some("");
12499    }
12500    Some(rest)
12501}
12502
12503fn strip_leading_java_modifier(value: &str) -> Option<&str> {
12504    const MODIFIERS: &[&str] = &[
12505        "public",
12506        "protected",
12507        "private",
12508        "abstract",
12509        "static",
12510        "final",
12511        "transient",
12512        "volatile",
12513        "synchronized",
12514        "native",
12515        "strictfp",
12516    ];
12517    MODIFIERS
12518        .iter()
12519        .find_map(|modifier| strip_leading_word(value, modifier))
12520}
12521
12522fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12523    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12524    let before = &declaration[..receiver_start];
12525    if find_identifier_occurrence(before, "val").is_none()
12526        && find_identifier_occurrence(before, "var").is_none()
12527    {
12528        return None;
12529    }
12530
12531    let after = declaration[receiver_start + receiver.len()..].trim_start();
12532    if let Some(type_text) = after.strip_prefix(':') {
12533        return normalize_receiver_type_name(read_type_prefix(type_text));
12534    }
12535    after
12536        .strip_prefix('=')
12537        .and_then(infer_kotlin_constructor_type)
12538}
12539
12540fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
12541    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
12542    if rest.trim_start().starts_with('(') {
12543        normalize_receiver_type_name(head)
12544    } else {
12545        None
12546    }
12547}
12548
12549fn read_type_prefix(value: &str) -> &str {
12550    let mut angle_depth = 0usize;
12551    for (index, ch) in value.char_indices() {
12552        match ch {
12553            '<' => angle_depth += 1,
12554            '>' => angle_depth = angle_depth.saturating_sub(1),
12555            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
12556                return value[..index].trim();
12557            }
12558            _ => {}
12559        }
12560    }
12561    value.trim()
12562}
12563
12564fn infer_cpp_receiver_type_from_scope(
12565    scope: tree_sitter::Node<'_>,
12566    source: &str,
12567    receiver: &str,
12568    call_line: u32,
12569) -> Option<String> {
12570    let lines = source.lines().collect::<Vec<_>>();
12571    if lines.is_empty() {
12572        return None;
12573    }
12574    let scope_start = scope.start_position().row as usize;
12575    let call_index = (call_line as usize)
12576        .saturating_sub(1)
12577        .min(lines.len().saturating_sub(1));
12578    for index in (scope_start..=call_index).rev() {
12579        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
12580            return Some(receiver_type);
12581        }
12582    }
12583    None
12584}
12585
12586fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
12587    for receiver_start in identifier_occurrences(line, receiver) {
12588        let after = line[receiver_start + receiver.len()..].trim_start();
12589        if after
12590            .chars()
12591            .next()
12592            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
12593        {
12594            continue;
12595        }
12596        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
12597        let normalized = normalize_cpp_type_name(type_text)?;
12598        if normalized == "auto" {
12599            if let Some(rhs) = after.strip_prefix('=') {
12600                return infer_cpp_auto_receiver_type(rhs);
12601            }
12602            continue;
12603        }
12604        return Some(normalized);
12605    }
12606    None
12607}
12608
12609fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
12610    let candidate = prefix
12611        .rsplit([';', '{', '}', '('])
12612        .next()
12613        .unwrap_or(prefix)
12614        .trim();
12615    if candidate.is_empty() || candidate.ends_with(',') {
12616        None
12617    } else {
12618        Some(candidate)
12619    }
12620}
12621
12622fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
12623    let without_templates = strip_angle_groups(type_text);
12624    let mut cleaned = String::with_capacity(without_templates.len());
12625    for token in without_templates.split_whitespace() {
12626        if matches!(
12627            token,
12628            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
12629        ) {
12630            continue;
12631        }
12632        if !cleaned.is_empty() {
12633            cleaned.push(' ');
12634        }
12635        cleaned.push_str(token);
12636    }
12637    let token = cleaned
12638        .split_whitespace()
12639        .last()
12640        .unwrap_or(cleaned.trim())
12641        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
12642        .trim_matches(['*', '&']);
12643    let simple = token.rsplit("::").next().unwrap_or(token).trim();
12644    if simple.is_empty() || cpp_non_type_token(simple) {
12645        None
12646    } else {
12647        Some(simple.to_string())
12648    }
12649}
12650
12651fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
12652    let rhs = rhs.trim_start();
12653    if let Some(after_new) = rhs.strip_prefix("new ") {
12654        return infer_cpp_constructor_type(after_new);
12655    }
12656    infer_cpp_make_template_type(rhs)
12657        .or_else(|| infer_cpp_constructor_type(rhs))
12658        .or_else(|| infer_cpp_factory_type(rhs))
12659}
12660
12661fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
12662    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12663    let normalized = normalize_cpp_type_name(head)?;
12664    if !normalized
12665        .chars()
12666        .next()
12667        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12668    {
12669        return None;
12670    }
12671    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12672        Some(normalized)
12673    } else {
12674        None
12675    }
12676}
12677
12678fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12679    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12680    if !rest.trim_start().starts_with('(') {
12681        return None;
12682    }
12683    let base = head.split('<').next().unwrap_or(head);
12684    let base_simple = base.rsplit("::").next().unwrap_or(base);
12685    if !matches!(base_simple, "make_unique" | "make_shared") {
12686        return None;
12687    }
12688    first_angle_arg(head).and_then(normalize_cpp_type_name)
12689}
12690
12691fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12692    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12693    if !rest.trim_start().starts_with('(') {
12694        return None;
12695    }
12696    let simple = head
12697        .split('<')
12698        .next()
12699        .unwrap_or(head)
12700        .rsplit("::")
12701        .next()
12702        .unwrap_or(head);
12703    for prefix in ["make", "create", "build"] {
12704        if let Some(suffix) = simple.strip_prefix(prefix) {
12705            if suffix
12706                .chars()
12707                .next()
12708                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12709            {
12710                return normalize_cpp_type_name(suffix);
12711            }
12712        }
12713    }
12714    None
12715}
12716
12717#[derive(Debug, Clone, Copy)]
12718enum JavaLikeInvocation {
12719    Kotlin,
12720    Cpp,
12721}
12722
12723fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12724    let value = value.trim_start();
12725    let mut end = 0usize;
12726    for (index, ch) in value.char_indices() {
12727        let allowed_separator = match flavor {
12728            JavaLikeInvocation::Kotlin => ch == '.',
12729            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12730        };
12731        if is_code_ident_char(ch) || allowed_separator {
12732            end = index + ch.len_utf8();
12733            continue;
12734        }
12735        break;
12736    }
12737    if end == 0 {
12738        return None;
12739    }
12740    let mut rest = &value[end..];
12741    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12742        let skipped = skip_balanced_angle(stripped)?;
12743        let rest_start = rest.len() - rest.trim_start().len();
12744        let angle_len = 1 + skipped;
12745        end += rest_start + angle_len;
12746        rest = &value[end..];
12747    }
12748    Some((value[..end].trim(), rest))
12749}
12750
12751fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12752    let mut depth = 1usize;
12753    for (index, ch) in value_after_open.char_indices() {
12754        match ch {
12755            '<' => depth += 1,
12756            '>' => {
12757                depth = depth.saturating_sub(1);
12758                if depth == 0 {
12759                    return Some(index + ch.len_utf8());
12760                }
12761            }
12762            _ => {}
12763        }
12764    }
12765    None
12766}
12767
12768fn first_angle_arg(value: &str) -> Option<&str> {
12769    let open = value.find('<')?;
12770    let inner_len = skip_balanced_angle(&value[open + 1..])?;
12771    let inner = &value[open + 1..open + inner_len];
12772    split_top_level_commas(inner).into_iter().next()
12773}
12774
12775fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12776    let without_generics = strip_angle_groups(type_text);
12777    let cleaned = without_generics
12778        .replace("[]", " ")
12779        .replace("...", " ")
12780        .replace(['?', '&', '*'], " ");
12781    let token = cleaned
12782        .split_whitespace()
12783        .last()
12784        .unwrap_or(cleaned.trim())
12785        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12786    let token = token.rsplit("::").next().unwrap_or(token);
12787    let simple = token.rsplit('.').next().unwrap_or(token).trim();
12788    if simple.is_empty()
12789        || java_like_primitive_type(simple)
12790        || !simple
12791            .chars()
12792            .next()
12793            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12794    {
12795        None
12796    } else {
12797        Some(simple.to_string())
12798    }
12799}
12800
12801fn simple_type_name(scoped_name: &str) -> Option<String> {
12802    scoped_name
12803        .rsplit("::")
12804        .find(|segment| !segment.is_empty())
12805        .and_then(normalize_receiver_type_name)
12806}
12807
12808fn strip_angle_groups(value: &str) -> String {
12809    let mut output = String::with_capacity(value.len());
12810    let mut depth = 0usize;
12811    for ch in value.chars() {
12812        match ch {
12813            '<' => {
12814                if depth == 0 {
12815                    output.push(' ');
12816                }
12817                depth += 1;
12818            }
12819            '>' => depth = depth.saturating_sub(1),
12820            _ if depth == 0 => output.push(ch),
12821            _ => {}
12822        }
12823    }
12824    output
12825}
12826
12827fn java_like_primitive_type(value: &str) -> bool {
12828    matches!(
12829        value,
12830        "boolean"
12831            | "byte"
12832            | "char"
12833            | "double"
12834            | "float"
12835            | "int"
12836            | "long"
12837            | "short"
12838            | "void"
12839            | "Boolean"
12840            | "Byte"
12841            | "Char"
12842            | "Double"
12843            | "Float"
12844            | "Int"
12845            | "Long"
12846            | "Short"
12847            | "Unit"
12848    )
12849}
12850
12851fn cpp_non_type_token(value: &str) -> bool {
12852    matches!(
12853        value,
12854        "return"
12855            | "if"
12856            | "else"
12857            | "for"
12858            | "while"
12859            | "do"
12860            | "switch"
12861            | "case"
12862            | "default"
12863            | "break"
12864            | "continue"
12865            | "goto"
12866            | "throw"
12867            | "new"
12868            | "delete"
12869            | "co_await"
12870            | "co_yield"
12871            | "co_return"
12872            | "static_cast"
12873            | "const_cast"
12874            | "dynamic_cast"
12875            | "reinterpret_cast"
12876            | "sizeof"
12877            | "alignof"
12878            | "typeid"
12879            | "and"
12880            | "or"
12881            | "not"
12882            | "xor"
12883    )
12884}
12885
12886fn receiver_is_bare_identifier(value: &str) -> bool {
12887    let mut chars = value.chars();
12888    let Some(first) = chars.next() else {
12889        return false;
12890    };
12891    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12892}
12893
12894fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12895    identifier_occurrences(value, needle).into_iter().next()
12896}
12897
12898fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12899    value
12900        .match_indices(needle)
12901        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12902        .collect()
12903}
12904
12905fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12906    let before = value[..start].chars().next_back();
12907    let after = value[start + len..].chars().next();
12908    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12909}
12910
12911fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12912    let stripped = value.strip_prefix(word)?;
12913    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12914        Some(stripped.trim_start())
12915    } else {
12916        None
12917    }
12918}
12919
12920fn is_code_ident_char(ch: char) -> bool {
12921    ch == '_' || ch.is_ascii_alphanumeric()
12922}
12923
12924fn infer_rust_receiver_type(
12925    project_root: &Path,
12926    reference: &NameMatchRef,
12927    source_cache: &mut DispatchSourceCache,
12928) -> ReceiverTypeInference {
12929    if matches!(reference.receiver.as_str(), "self" | "Self") {
12930        return enclosing_type_from_scoped_name(&reference.caller_symbol)
12931            .map(ReceiverTypeInference::Known)
12932            .unwrap_or(ReceiverTypeInference::Unknown);
12933    }
12934
12935    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12936        return ReceiverTypeInference::Known(reference.receiver.clone());
12937    }
12938
12939    if let Some(receiver_type) = reference
12940        .caller_signature
12941        .as_deref()
12942        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12943    {
12944        return ReceiverTypeInference::Known(receiver_type);
12945    }
12946
12947    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12948}
12949
12950fn infer_rust_direct_self_field_receiver_type(
12951    project_root: &Path,
12952    reference: &NameMatchRef,
12953    source_cache: &mut DispatchSourceCache,
12954) -> ReceiverTypeInference {
12955    if reference.colon_dispatch {
12956        return ReceiverTypeInference::Unknown;
12957    }
12958    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12959        return ReceiverTypeInference::Unknown;
12960    };
12961    if field_name != reference.receiver {
12962        return ReceiverTypeInference::Unknown;
12963    }
12964
12965    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12966        return ReceiverTypeInference::Unknown;
12967    };
12968    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12969        return ReceiverTypeInference::KnownButUnresolved;
12970    };
12971    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12972    else {
12973        return ReceiverTypeInference::Unknown;
12974    };
12975    let Some(impl_node) =
12976        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12977    else {
12978        return ReceiverTypeInference::Unknown;
12979    };
12980    if impl_node.child_by_field_name("trait").is_some()
12981        || impl_node.child_by_field_name("type_parameters").is_some()
12982    {
12983        return ReceiverTypeInference::KnownButUnresolved;
12984    }
12985    let Some(impl_target) = impl_node.child_by_field_name("type") else {
12986        return ReceiverTypeInference::KnownButUnresolved;
12987    };
12988    if impl_target.kind() != "type_identifier"
12989        || node_text(impl_target, &parsed.source) != impl_type
12990    {
12991        return ReceiverTypeInference::KnownButUnresolved;
12992    }
12993
12994    let module_scope = rust_module_scope(impl_node);
12995    let Some(struct_node) = find_unique_rust_struct(
12996        parsed.tree.root_node(),
12997        &parsed.source,
12998        struct_name,
12999        &module_scope,
13000    ) else {
13001        return ReceiverTypeInference::KnownButUnresolved;
13002    };
13003    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
13004    else {
13005        return ReceiverTypeInference::KnownButUnresolved;
13006    };
13007    if field_type.kind() != "type_identifier" {
13008        return ReceiverTypeInference::KnownButUnresolved;
13009    }
13010    let field_type_name = node_text(field_type, &parsed.source);
13011    if find_unique_rust_struct(
13012        parsed.tree.root_node(),
13013        &parsed.source,
13014        field_type_name,
13015        &module_scope,
13016    )
13017    .is_none()
13018    {
13019        return ReceiverTypeInference::KnownButUnresolved;
13020    }
13021
13022    ReceiverTypeInference::RustDirectSelfField {
13023        receiver_type: field_type_name.to_string(),
13024        declaration_file: reference.caller_file.clone(),
13025        module_scope,
13026    }
13027}
13028
13029fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
13030    let (base, field) = receiver_expression.split_once('.')?;
13031    let base = base.trim();
13032    let field = field.trim();
13033    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
13034}
13035
13036fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
13037    let name = value.rsplit("::").next()?.trim();
13038    (!name.is_empty()
13039        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
13040        && name.chars().all(is_rust_ident_char))
13041    .then_some(name)
13042}
13043
13044fn find_enclosing_rust_impl_node<'tree>(
13045    root: tree_sitter::Node<'tree>,
13046    line: u32,
13047) -> Option<tree_sitter::Node<'tree>> {
13048    let mut best = None;
13049    let mut stack = vec![root];
13050    while let Some(node) = stack.pop() {
13051        if !node_contains_line(node, line) {
13052            continue;
13053        }
13054        if node.kind() == "impl_item" {
13055            best = tighter_node(best, node);
13056        }
13057        push_named_children(node, &mut stack);
13058    }
13059    best
13060}
13061
13062fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
13063    let mut scope = Vec::new();
13064    let mut current = node.parent();
13065    while let Some(parent) = current {
13066        if parent.kind() == "mod_item" {
13067            scope.push((parent.start_byte(), parent.end_byte()));
13068        }
13069        current = parent.parent();
13070    }
13071    scope.reverse();
13072    scope
13073}
13074
13075fn find_unique_rust_struct<'tree>(
13076    root: tree_sitter::Node<'tree>,
13077    source: &str,
13078    expected_name: &str,
13079    module_scope: &[(usize, usize)],
13080) -> Option<tree_sitter::Node<'tree>> {
13081    let mut found = None;
13082    let mut stack = vec![root];
13083    while let Some(node) = stack.pop() {
13084        if node.kind() == "struct_item"
13085            && rust_module_scope(node) == module_scope
13086            && node.child_by_field_name("type_parameters").is_none()
13087            && declaration_name(node, source) == Some(expected_name)
13088        {
13089            if found.is_some() {
13090                return None;
13091            }
13092            found = Some(node);
13093        }
13094        push_named_children(node, &mut stack);
13095    }
13096    found
13097}
13098
13099fn rust_struct_field_type_node<'tree>(
13100    struct_node: tree_sitter::Node<'tree>,
13101    source: &str,
13102    field_name: &str,
13103) -> Option<tree_sitter::Node<'tree>> {
13104    let fields = struct_node.child_by_field_name("body")?;
13105    if fields.kind() != "field_declaration_list" {
13106        return None;
13107    }
13108    for index in 0..fields.named_child_count() {
13109        let field = fields.named_child(index as u32)?;
13110        if field.kind() != "field_declaration"
13111            || declaration_name(field, source) != Some(field_name)
13112        {
13113            continue;
13114        }
13115        return field.child_by_field_name("type");
13116    }
13117    None
13118}
13119
13120fn rust_receiver_looks_type_like(receiver: &str) -> bool {
13121    receiver
13122        .chars()
13123        .next()
13124        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
13125}
13126
13127fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
13128    scoped_name
13129        .rsplit_once("::")
13130        .map(|(enclosing, _)| enclosing)
13131        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
13132        .map(ToString::to_string)
13133}
13134
13135fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
13136    let params = signature_parameter_text(signature)?;
13137    for param in split_top_level_commas(params) {
13138        let Some((pattern, type_text)) = param.split_once(':') else {
13139            continue;
13140        };
13141        let Some(name) = rust_parameter_name(pattern) else {
13142            continue;
13143        };
13144        if name == receiver {
13145            return normalize_rust_receiver_type(type_text);
13146        }
13147    }
13148    None
13149}
13150
13151fn signature_parameter_text(signature: &str) -> Option<&str> {
13152    let open = signature.find('(')?;
13153    let mut depth = 0usize;
13154    for (offset, ch) in signature[open..].char_indices() {
13155        match ch {
13156            '(' => depth += 1,
13157            ')' => {
13158                depth = depth.saturating_sub(1);
13159                if depth == 0 {
13160                    return Some(&signature[open + 1..open + offset]);
13161                }
13162            }
13163            _ => {}
13164        }
13165    }
13166    None
13167}
13168
13169fn split_top_level_commas(value: &str) -> Vec<&str> {
13170    let mut parts = Vec::new();
13171    let mut start = 0usize;
13172    let mut angle_depth = 0usize;
13173    let mut paren_depth = 0usize;
13174    let mut bracket_depth = 0usize;
13175    for (index, ch) in value.char_indices() {
13176        match ch {
13177            '<' => angle_depth += 1,
13178            '>' => angle_depth = angle_depth.saturating_sub(1),
13179            '(' => paren_depth += 1,
13180            ')' => paren_depth = paren_depth.saturating_sub(1),
13181            '[' => bracket_depth += 1,
13182            ']' => bracket_depth = bracket_depth.saturating_sub(1),
13183            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
13184                let part = value[start..index].trim();
13185                if !part.is_empty() {
13186                    parts.push(part);
13187                }
13188                start = index + ch.len_utf8();
13189            }
13190            _ => {}
13191        }
13192    }
13193    let part = value[start..].trim();
13194    if !part.is_empty() {
13195        parts.push(part);
13196    }
13197    parts
13198}
13199
13200fn rust_parameter_name(pattern: &str) -> Option<&str> {
13201    let mut pattern = pattern.trim();
13202    if let Some(stripped) = pattern.strip_prefix("mut ") {
13203        pattern = stripped.trim_start();
13204    }
13205    pattern
13206        .rsplit(|ch: char| !is_rust_ident_char(ch))
13207        .find(|part| !part.is_empty())
13208}
13209
13210fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
13211    let mut ty = strip_leading_rust_type_modifiers(type_text);
13212    let owned_inner;
13213    if let Some(inner) = single_outer_generic_arg(ty) {
13214        owned_inner = inner.trim().to_string();
13215        ty = strip_leading_rust_type_modifiers(&owned_inner);
13216    }
13217    rust_base_type_ident(ty)
13218}
13219
13220fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
13221    loop {
13222        ty = ty.trim_start();
13223        if let Some(stripped) = ty.strip_prefix('&') {
13224            ty = stripped.trim_start();
13225            if let Some(stripped) = strip_leading_lifetime(ty) {
13226                ty = stripped.trim_start();
13227            }
13228            if let Some(stripped) = ty.strip_prefix("mut ") {
13229                ty = stripped.trim_start();
13230            }
13231            continue;
13232        }
13233        if let Some(stripped) = ty.strip_prefix("mut ") {
13234            ty = stripped.trim_start();
13235            continue;
13236        }
13237        if let Some(stripped) = ty.strip_prefix("dyn ") {
13238            ty = stripped.trim_start();
13239            continue;
13240        }
13241        if let Some(stripped) = ty.strip_prefix("impl ") {
13242            ty = stripped.trim_start();
13243            continue;
13244        }
13245        break ty.trim();
13246    }
13247}
13248
13249fn strip_leading_lifetime(value: &str) -> Option<&str> {
13250    let mut chars = value.char_indices();
13251    let (_, first) = chars.next()?;
13252    if first != '\'' {
13253        return None;
13254    }
13255    for (index, ch) in chars {
13256        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
13257            return Some(&value[index..]);
13258        }
13259    }
13260    Some("")
13261}
13262
13263fn single_outer_generic_arg(ty: &str) -> Option<&str> {
13264    let ty = ty.trim();
13265    let open = ty.find('<')?;
13266    let mut depth = 0usize;
13267    let mut close = None;
13268    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
13269        match ch {
13270            '<' => depth += 1,
13271            '>' => {
13272                depth = depth.saturating_sub(1);
13273                if depth == 0 {
13274                    close = Some(index);
13275                    break;
13276                }
13277            }
13278            _ => {}
13279        }
13280    }
13281    let close = close?;
13282    if !ty[close + 1..].trim().is_empty() {
13283        return None;
13284    }
13285    let inner = &ty[open + 1..close];
13286    let args = split_top_level_commas(inner);
13287    match args.as_slice() {
13288        [arg] => Some(*arg),
13289        _ => None,
13290    }
13291}
13292
13293fn rust_base_type_ident(ty: &str) -> Option<String> {
13294    let ty = ty.trim();
13295    let head = ty
13296        .split([' ', '+', '='])
13297        .find(|part| !part.is_empty())
13298        .unwrap_or(ty);
13299    let head = head.split('<').next().unwrap_or(head).trim();
13300    let ident = head
13301        .rsplit("::")
13302        .next()
13303        .unwrap_or(head)
13304        .trim_matches(|ch: char| !is_rust_ident_char(ch));
13305    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
13306        None
13307    } else {
13308        Some(ident.to_string())
13309    }
13310}
13311
13312fn is_rust_ident_char(ch: char) -> bool {
13313    ch == '_' || ch.is_ascii_alphanumeric()
13314}
13315
13316fn select_rust_direct_self_field_candidate(
13317    project_root: &Path,
13318    reference: &NameMatchRef,
13319    candidates: &[NameMatchCandidate],
13320    receiver_type: &str,
13321    declaration_file: &str,
13322    declaration_scope: &[(usize, usize)],
13323    source_cache: &mut DispatchSourceCache,
13324) -> Option<NameMatchCandidate> {
13325    let eligible = candidates
13326        .iter()
13327        .filter(|candidate| candidate.node_id != reference.caller_node)
13328        .filter(|candidate| {
13329            type_candidate_matches(candidate, receiver_type, &reference.method_name)
13330        })
13331        .filter(|candidate| {
13332            rust_direct_self_field_candidate_matches_scope(
13333                project_root,
13334                candidate,
13335                receiver_type,
13336                declaration_file,
13337                declaration_scope,
13338                source_cache,
13339            )
13340        })
13341        .collect::<Vec<_>>();
13342    match eligible.as_slice() {
13343        [candidate] => Some((**candidate).clone()),
13344        _ => None,
13345    }
13346}
13347
13348fn rust_direct_self_field_candidate_matches_scope(
13349    project_root: &Path,
13350    candidate: &NameMatchCandidate,
13351    receiver_type: &str,
13352    declaration_file: &str,
13353    declaration_scope: &[(usize, usize)],
13354    source_cache: &mut DispatchSourceCache,
13355) -> bool {
13356    if candidate.file_path != declaration_file {
13357        return false;
13358    }
13359    let Some(parsed) = parsed_dispatch_source_for_file(
13360        project_root,
13361        &candidate.file_path,
13362        "rust",
13363        LangId::Rust,
13364        source_cache,
13365    ) else {
13366        return false;
13367    };
13368    let Some(impl_node) =
13369        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
13370    else {
13371        return false;
13372    };
13373    if impl_node.child_by_field_name("trait").is_some()
13374        || impl_node.child_by_field_name("type_parameters").is_some()
13375    {
13376        return false;
13377    }
13378    let Some(impl_target) = impl_node.child_by_field_name("type") else {
13379        return false;
13380    };
13381    impl_target.kind() == "type_identifier"
13382        && node_text(impl_target, &parsed.source) == receiver_type
13383        && rust_module_scope(impl_node) == declaration_scope
13384}
13385
13386fn select_type_match_candidate(
13387    reference: &NameMatchRef,
13388    candidates: &[NameMatchCandidate],
13389    receiver_type: &str,
13390) -> Option<NameMatchCandidate> {
13391    let candidates = candidates
13392        .iter()
13393        .filter(|candidate| candidate.node_id != reference.caller_node)
13394        .filter(|candidate| {
13395            type_candidate_matches(candidate, receiver_type, &reference.method_name)
13396        })
13397        .collect::<Vec<_>>();
13398    match candidates.as_slice() {
13399        [candidate] => Some((**candidate).clone()),
13400        _ => None,
13401    }
13402}
13403
13404fn type_candidate_matches(
13405    candidate: &NameMatchCandidate,
13406    receiver_type: &str,
13407    method_name: &str,
13408) -> bool {
13409    let normalized_type = receiver_type.replace('.', "::");
13410    let suffix = format!("{normalized_type}::{method_name}");
13411    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
13412}
13413
13414fn select_name_match_candidate(
13415    reference: &NameMatchRef,
13416    candidates: &[NameMatchCandidate],
13417) -> Option<NameMatchCandidate> {
13418    let candidates = candidates
13419        .iter()
13420        .filter(|candidate| candidate.node_id != reference.caller_node)
13421        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
13422        .collect::<Vec<_>>();
13423    match candidates.as_slice() {
13424        [] => None,
13425        [candidate] => Some((**candidate).clone()),
13426        _ => select_scored_name_match_candidate(reference, &candidates),
13427    }
13428}
13429
13430fn candidate_allowed_for_reference(
13431    reference: &NameMatchRef,
13432    candidate: &NameMatchCandidate,
13433) -> bool {
13434    if !reference.colon_dispatch {
13435        return true;
13436    }
13437
13438    candidate.kind == "method"
13439        && candidate
13440            .scoped_name
13441            .split("::")
13442            .any(|segment| segment == reference.receiver)
13443}
13444
13445fn select_scored_name_match_candidate(
13446    reference: &NameMatchRef,
13447    candidates: &[&NameMatchCandidate],
13448) -> Option<NameMatchCandidate> {
13449    let receiver_words = split_camel_case(&reference.receiver);
13450    if receiver_words.is_empty() {
13451        return None;
13452    }
13453
13454    let mut best: Option<(&NameMatchCandidate, f64)> = None;
13455    let mut tied_best = false;
13456    for candidate in candidates {
13457        let candidate_words = split_camel_case(&candidate.scoped_name);
13458        let overlap = receiver_words
13459            .iter()
13460            .filter(|receiver_word| {
13461                candidate_words
13462                    .iter()
13463                    .any(|candidate_word| candidate_word == *receiver_word)
13464            })
13465            .count() as f64;
13466        let score =
13467            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
13468        match best {
13469            None => {
13470                best = Some((*candidate, score));
13471                tied_best = false;
13472            }
13473            Some((_, best_score)) if score > best_score => {
13474                best = Some((*candidate, score));
13475                tied_best = false;
13476            }
13477            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
13478                tied_best = true;
13479            }
13480            _ => {}
13481        }
13482    }
13483
13484    let (candidate, score) = best?;
13485    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
13486        Some(candidate.clone())
13487    } else {
13488        None
13489    }
13490}
13491
13492fn method_name_match_denylisted(method_name: &str) -> bool {
13493    matches!(
13494        method_name,
13495        "and_then"
13496            | "as_bytes"
13497            | "as_deref"
13498            | "as_mut"
13499            | "as_ref"
13500            | "as_str"
13501            | "borrow"
13502            | "borrow_mut"
13503            | "clear"
13504            | "clone"
13505            | "collect"
13506            | "contains"
13507            | "contains_key"
13508            | "count"
13509            | "dedup"
13510            | "default"
13511            | "drain"
13512            | "ends_with"
13513            | "entry"
13514            | "err"
13515            | "expect"
13516            | "extend"
13517            | "filter"
13518            | "filter_map"
13519            | "find"
13520            | "from"
13521            | "get"
13522            | "get_mut"
13523            | "insert"
13524            | "into"
13525            | "into_iter"
13526            | "is_empty"
13527            | "is_err"
13528            | "is_none"
13529            | "is_ok"
13530            | "is_some"
13531            | "iter"
13532            | "iter_mut"
13533            | "join"
13534            | "len"
13535            | "lock"
13536            | "map"
13537            | "map_err"
13538            | "max"
13539            | "min"
13540            | "new"
13541            | "next"
13542            | "ok"
13543            | "or_default"
13544            | "or_else"
13545            | "or_insert"
13546            | "or_insert_with"
13547            | "parse"
13548            | "pop"
13549            | "position"
13550            | "push"
13551            | "read"
13552            | "recv"
13553            | "remove"
13554            | "replace"
13555            | "retain"
13556            | "send"
13557            | "sort"
13558            | "sort_by"
13559            | "split"
13560            | "starts_with"
13561            | "sum"
13562            | "take"
13563            | "to_owned"
13564            | "to_string"
13565            | "trim"
13566            | "try_from"
13567            | "try_into"
13568            | "unwrap"
13569            | "unwrap_or"
13570            | "unwrap_or_default"
13571            | "unwrap_or_else"
13572            | "with_capacity"
13573            | "write"
13574    )
13575}
13576
13577fn split_camel_case(value: &str) -> Vec<String> {
13578    let chars = value.chars().collect::<Vec<_>>();
13579    let mut normalized = String::with_capacity(value.len() + 8);
13580    for (index, ch) in chars.iter().enumerate() {
13581        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
13582        let next = chars.get(index + 1);
13583        let is_separator = ch.is_whitespace()
13584            || matches!(
13585                ch,
13586                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
13587            );
13588        if is_separator {
13589            normalized.push(' ');
13590            continue;
13591        }
13592        let camel_boundary = previous.is_some_and(|prev| {
13593            (prev.is_lowercase() && ch.is_uppercase())
13594                || (prev.is_ascii_digit() && ch.is_alphabetic())
13595                || (prev.is_uppercase()
13596                    && ch.is_uppercase()
13597                    && next.is_some_and(|next| next.is_lowercase()))
13598        });
13599        if camel_boundary {
13600            normalized.push(' ');
13601        }
13602        normalized.push(*ch);
13603    }
13604
13605    normalized
13606        .split_whitespace()
13607        .filter(|word| word.len() > 1)
13608        .map(|word| word.to_ascii_lowercase())
13609        .collect()
13610}
13611
13612fn compute_path_proximity(left: &str, right: &str) -> f64 {
13613    let left_dirs = left
13614        .rsplit_once('/')
13615        .map(|(dir, _)| dir)
13616        .unwrap_or_default()
13617        .split('/')
13618        .filter(|part| !part.is_empty());
13619    let right_dirs = right
13620        .rsplit_once('/')
13621        .map(|(dir, _)| dir)
13622        .unwrap_or_default()
13623        .split('/')
13624        .filter(|part| !part.is_empty());
13625
13626    let shared = left_dirs
13627        .zip(right_dirs)
13628        .take_while(|(left, right)| left == right)
13629        .count();
13630    ((shared as f64) * 0.05).min(0.5)
13631}
13632
13633fn mark_backend_state(
13634    tx: &Transaction<'_>,
13635    project_root: &Path,
13636    rel_path: &str,
13637    content_hash: Option<&blake3::Hash>,
13638    status: &str,
13639) -> Result<()> {
13640    clear_backend_state_for_file(tx, project_root, rel_path)?;
13641    let hash = content_hash
13642        .map(|hash| hash_to_hex(*hash))
13643        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
13644    tx.execute(
13645        "INSERT OR REPLACE INTO backend_file_state(
13646            backend, workspace_root, file_path, content_hash, status, updated_at
13647        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
13648        params![
13649            BACKEND_TREESITTER,
13650            project_root.display().to_string(),
13651            rel_path,
13652            hash,
13653            status,
13654            unix_seconds_now(),
13655        ],
13656    )?;
13657    Ok(())
13658}
13659
13660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13661enum StalePathStatus {
13662    Present,
13663    Absent,
13664    Unreadable,
13665}
13666
13667fn stale_backend_file_paths(
13668    conn: &Connection,
13669    project_root: &Path,
13670    distinct: bool,
13671) -> Result<Vec<String>> {
13672    let distinct = if distinct { "DISTINCT " } else { "" };
13673    let sql = format!(
13674        "SELECT {distinct}file_path FROM backend_file_state
13675         WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
13676         ORDER BY file_path"
13677    );
13678    let mut stmt = conn.prepare(&sql)?;
13679    let rows = stmt.query_map(
13680        params![BACKEND_TREESITTER, project_root.display().to_string()],
13681        |row| row.get::<_, String>(0),
13682    )?;
13683    rows.collect::<std::result::Result<Vec<_>, _>>()
13684        .map_err(Into::into)
13685}
13686
13687fn stale_path_census(conn: &Connection, project_root: &Path) -> Result<StalePathCensus> {
13688    let mut census = StalePathCensus::default();
13689    for rel_path in stale_backend_file_paths(conn, project_root, false)? {
13690        census.stale += 1;
13691        match stale_path_status(project_root, &rel_path) {
13692            StalePathStatus::Present => {}
13693            StalePathStatus::Absent => census.absent += 1,
13694            StalePathStatus::Unreadable => census.unreadable += 1,
13695        }
13696    }
13697    Ok(census)
13698}
13699
13700fn stale_path_status(project_root: &Path, rel_path: &str) -> StalePathStatus {
13701    let requested = project_root.join(rel_path);
13702    let Ok((path, normalized_rel_path)) = normalize_project_file_path(project_root, &requested)
13703    else {
13704        return StalePathStatus::Unreadable;
13705    };
13706    if normalized_rel_path != rel_path.replace('\\', "/") {
13707        return StalePathStatus::Unreadable;
13708    }
13709    match std::fs::metadata(path) {
13710        Ok(_) => StalePathStatus::Present,
13711        Err(error) if error.kind() == std::io::ErrorKind::NotFound => StalePathStatus::Absent,
13712        Err(_) => StalePathStatus::Unreadable,
13713    }
13714}
13715
13716fn clear_backend_state_for_file(
13717    tx: &Transaction<'_>,
13718    project_root: &Path,
13719    rel_path: &str,
13720) -> Result<()> {
13721    tx.execute(
13722        "DELETE FROM backend_file_state
13723         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13724        params![
13725            BACKEND_TREESITTER,
13726            project_root.display().to_string(),
13727            rel_path
13728        ],
13729    )?;
13730    Ok(())
13731}
13732
13733/// Mark a file whose graph bytes were just confirmed current as fresh.
13734///
13735/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
13736/// leftover `status='stale'` row from a failed refresh would keep blocking
13737/// dead-code projection even though the graph still matches disk.
13738fn clear_stale_backend_status_for_file(
13739    tx: &Transaction<'_>,
13740    project_root: &Path,
13741    rel_path: &str,
13742) -> Result<()> {
13743    tx.execute(
13744        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13745         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13746        params![
13747            BACKEND_TREESITTER,
13748            project_root.display().to_string(),
13749            rel_path,
13750            unix_seconds_now(),
13751        ],
13752    )?;
13753    Ok(())
13754}
13755
13756fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13757    conn.query_row(
13758        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13759        params![rel_path],
13760        |row| {
13761            let hash_text: String = row.get(1)?;
13762            Ok(FileRow {
13763                surface_fingerprint: row.get(0)?,
13764                freshness: FileFreshness {
13765                    content_hash: hash_from_hex(&hash_text)
13766                        .unwrap_or_else(cache_freshness::zero_hash),
13767                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13768                    size: row.get::<_, i64>(3)? as u64,
13769                },
13770            })
13771        },
13772    )
13773    .optional()
13774    .map_err(CallGraphStoreError::from)
13775}
13776
13777fn stored_node_ids_match_extract(
13778    tx: &Transaction<'_>,
13779    rel_path: &str,
13780    extract: &FileExtract,
13781) -> Result<bool> {
13782    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13783    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13784    let mut stored = BTreeSet::new();
13785    for row in rows {
13786        stored.insert(row?);
13787    }
13788    let extracted = extract
13789        .nodes
13790        .iter()
13791        .map(|node| node.id.clone())
13792        .collect::<BTreeSet<_>>();
13793    Ok(stored == extracted)
13794}
13795
13796/// Compare every persisted graph row that comes from this file before rewriting it.
13797/// Ranges and reference byte offsets are part of the key because queries expose
13798/// source locations; equal names and edges are not enough after a body shift.
13799fn stored_extract_matches(
13800    tx: &Transaction<'_>,
13801    rel_path: &str,
13802    extract: &FileExtract,
13803    index: &ProjectIndex<'_>,
13804) -> Result<bool> {
13805    let stored_file = tx
13806        .query_row(
13807            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13808            params![rel_path],
13809            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13810        )
13811        .optional()?;
13812    if stored_file
13813        != Some((
13814            lang_label(extract.lang).to_string(),
13815            extract.surface_fingerprint.clone(),
13816        ))
13817    {
13818        return Ok(false);
13819    }
13820
13821    let mut stored_nodes_stmt = tx.prepare(
13822        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13823                end_line, end_col, range_ordinal, signature, exported,
13824                is_default_export, is_type_like, is_callgraph_entry_point, provenance
13825         FROM nodes WHERE file_path = ?1",
13826    )?;
13827    let stored_nodes = stored_nodes_stmt
13828        .query_map(params![rel_path], |row| {
13829            Ok(serde_json::json!([
13830                row.get::<_, String>(0)?,
13831                row.get::<_, String>(1)?,
13832                row.get::<_, String>(2)?,
13833                row.get::<_, String>(3)?,
13834                row.get::<_, String>(4)?,
13835                row.get::<_, i64>(5)?,
13836                row.get::<_, i64>(6)?,
13837                row.get::<_, i64>(7)?,
13838                row.get::<_, i64>(8)?,
13839                row.get::<_, i64>(9)?,
13840                row.get::<_, Option<String>>(10)?,
13841                row.get::<_, i64>(11)?,
13842                row.get::<_, i64>(12)?,
13843                row.get::<_, i64>(13)?,
13844                row.get::<_, i64>(14)?,
13845                row.get::<_, String>(15)?,
13846            ])
13847            .to_string())
13848        })?
13849        .collect::<rusqlite::Result<Vec<_>>>()?;
13850    let expected_nodes = extract
13851        .nodes
13852        .iter()
13853        .map(|node| {
13854            serde_json::json!([
13855                node.id,
13856                node.file_path,
13857                node.name,
13858                node.scoped_name,
13859                node.kind,
13860                node.range.start_line,
13861                node.range.start_col,
13862                node.range.end_line,
13863                node.range.end_col,
13864                node.range_ordinal,
13865                node.signature,
13866                bool_int(node.exported),
13867                bool_int(node.is_default_export),
13868                bool_int(node.is_type_like),
13869                bool_int(node.is_callgraph_entry_point),
13870                PROVENANCE_TREESITTER,
13871            ])
13872            .to_string()
13873        })
13874        .collect::<Vec<_>>();
13875    let mut stored_nodes = stored_nodes;
13876    let mut expected_nodes = expected_nodes;
13877    stored_nodes.sort();
13878    expected_nodes.sort();
13879    if stored_nodes != expected_nodes {
13880        return Ok(false);
13881    }
13882
13883    let resolved_refs = extract
13884        .raw_refs
13885        .iter()
13886        .cloned()
13887        .map(|raw| resolve_ref(raw, index))
13888        .collect::<Result<Vec<_>>>()?;
13889    let mut stored_refs_stmt = tx.prepare(
13890        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13891                module_path, import_kind, local_name, requested_name, namespace_alias,
13892                wildcard, line, byte_start, byte_end, status, target_node,
13893                target_file, target_symbol, provenance
13894         FROM refs WHERE caller_file = ?1",
13895    )?;
13896    let stored_refs = stored_refs_stmt
13897        .query_map(params![rel_path], |row| {
13898            Ok(serde_json::json!([
13899                row.get::<_, String>(0)?,
13900                row.get::<_, Option<String>>(1)?,
13901                row.get::<_, String>(2)?,
13902                row.get::<_, String>(3)?,
13903                row.get::<_, Option<String>>(4)?,
13904                row.get::<_, Option<String>>(5)?,
13905                row.get::<_, Option<String>>(6)?,
13906                row.get::<_, Option<String>>(7)?,
13907                row.get::<_, Option<String>>(8)?,
13908                row.get::<_, Option<String>>(9)?,
13909                row.get::<_, Option<String>>(10)?,
13910                row.get::<_, i64>(11)?,
13911                row.get::<_, i64>(12)?,
13912                row.get::<_, i64>(13)?,
13913                row.get::<_, i64>(14)?,
13914                row.get::<_, String>(15)?,
13915                row.get::<_, Option<String>>(16)?,
13916                row.get::<_, Option<String>>(17)?,
13917                row.get::<_, Option<String>>(18)?,
13918                row.get::<_, String>(19)?,
13919            ])
13920            .to_string())
13921        })?
13922        .collect::<rusqlite::Result<Vec<_>>>()?;
13923    let expected_refs = resolved_refs
13924        .iter()
13925        .map(|resolved| {
13926            let raw = &resolved.raw;
13927            serde_json::json!([
13928                raw.ref_id,
13929                raw.caller_node,
13930                raw.caller_file,
13931                raw.kind,
13932                raw.short_name,
13933                raw.full_ref,
13934                raw.module_path,
13935                raw.import_kind,
13936                raw.local_name,
13937                raw.requested_name,
13938                raw.namespace_alias,
13939                bool_int(raw.wildcard),
13940                raw.line,
13941                raw.byte_start,
13942                raw.byte_end,
13943                resolved.status,
13944                resolved.target_node,
13945                resolved.target_file,
13946                resolved.target_symbol,
13947                ref_provenance(raw),
13948            ])
13949            .to_string()
13950        })
13951        .collect::<Vec<_>>();
13952    let mut stored_refs = stored_refs;
13953    let mut expected_refs = expected_refs;
13954    stored_refs.sort();
13955    expected_refs.sort();
13956    if stored_refs != expected_refs {
13957        return Ok(false);
13958    }
13959
13960    let mut stored_edges_stmt = tx.prepare(
13961        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13962                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13963         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13964         WHERE r.caller_file = ?1 AND e.provenance = ?2",
13965    )?;
13966    let stored_edges = stored_edges_stmt
13967        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13968            Ok(serde_json::json!([
13969                row.get::<_, String>(0)?,
13970                row.get::<_, String>(1)?,
13971                row.get::<_, String>(2)?,
13972                row.get::<_, Option<String>>(3)?,
13973                row.get::<_, String>(4)?,
13974                row.get::<_, String>(5)?,
13975                row.get::<_, String>(6)?,
13976                row.get::<_, i64>(7)?,
13977                row.get::<_, String>(8)?,
13978            ])
13979            .to_string())
13980        })?
13981        .collect::<rusqlite::Result<Vec<_>>>()?;
13982    let expected_edges = resolved_refs
13983        .iter()
13984        .filter_map(|resolved| {
13985            resolved.edge.as_ref().map(|edge| {
13986                serde_json::json!([
13987                    edge.edge_id,
13988                    resolved.raw.ref_id,
13989                    edge.source_node,
13990                    edge.target_node,
13991                    edge.target_file,
13992                    edge.target_symbol,
13993                    edge.kind,
13994                    edge.line,
13995                    ref_provenance(&resolved.raw),
13996                ])
13997                .to_string()
13998            })
13999        })
14000        .collect::<Vec<_>>();
14001    let mut stored_edges = stored_edges;
14002    let mut expected_edges = expected_edges;
14003    stored_edges.sort();
14004    expected_edges.sort();
14005    if stored_edges != expected_edges {
14006        return Ok(false);
14007    }
14008
14009    let mut stored_dependencies_stmt =
14010        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
14011    let stored_dependencies = stored_dependencies_stmt
14012        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
14013        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
14014    let expected_dependencies = extract
14015        .raw_refs
14016        .iter()
14017        .flat_map(|raw| raw.dependencies.iter().cloned())
14018        .collect::<BTreeSet<_>>();
14019    if stored_dependencies != expected_dependencies {
14020        return Ok(false);
14021    }
14022
14023    let mut stored_hints_stmt = tx.prepare(
14024        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
14025         FROM dispatch_hints WHERE file = ?1",
14026    )?;
14027    let stored_hints = stored_hints_stmt
14028        .query_map(params![rel_path], |row| {
14029            Ok(serde_json::json!([
14030                row.get::<_, String>(0)?,
14031                row.get::<_, String>(1)?,
14032                row.get::<_, String>(2)?,
14033                row.get::<_, String>(3)?,
14034                row.get::<_, i64>(4)?,
14035                row.get::<_, i64>(5)?,
14036                row.get::<_, i64>(6)?,
14037                row.get::<_, String>(7)?,
14038            ])
14039            .to_string())
14040        })?
14041        .collect::<rusqlite::Result<Vec<_>>>()?;
14042    let expected_hints = extract
14043        .dispatch_hints
14044        .iter()
14045        .map(|hint| {
14046            serde_json::json!([
14047                hint.id,
14048                hint.method_name,
14049                hint.caller_node,
14050                hint.file,
14051                hint.line,
14052                hint.byte_start,
14053                hint.byte_end,
14054                PROVENANCE_TREESITTER,
14055            ])
14056            .to_string()
14057        })
14058        .collect::<Vec<_>>();
14059    let mut stored_hints = stored_hints;
14060    let mut expected_hints = expected_hints;
14061    stored_hints.sort();
14062    expected_hints.sort();
14063    Ok(stored_hints == expected_hints)
14064}
14065
14066fn update_file_fresh_metadata(
14067    tx: &Transaction<'_>,
14068    project_root: &Path,
14069    rel_path: &str,
14070    hash: &blake3::Hash,
14071    mtime: SystemTime,
14072    size: u64,
14073) -> Result<()> {
14074    tx.execute(
14075        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
14076         WHERE path = ?1",
14077        params![
14078            rel_path,
14079            hash_to_hex(*hash),
14080            system_time_to_ns(mtime),
14081            size as i64,
14082            unix_seconds_now()
14083        ],
14084    )?;
14085    tx.execute(
14086        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
14087         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
14088        params![
14089            BACKEND_TREESITTER,
14090            rel_path,
14091            hash_to_hex(*hash),
14092            project_root.display().to_string(),
14093            unix_seconds_now(),
14094        ],
14095    )?;
14096    Ok(())
14097}
14098
14099#[derive(Debug, Clone, PartialEq, Eq)]
14100struct DependentRefSelection {
14101    ref_id: String,
14102    caller_file: String,
14103}
14104
14105fn ref_ids_depending_on(
14106    conn: &Connection,
14107    project_root: &Path,
14108    rel_path: &str,
14109) -> Result<Vec<DependentRefSelection>> {
14110    let mut stmt = conn.prepare(
14111        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
14112         FROM refs r
14113         WHERE r.caller_file IN (
14114             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
14115         )
14116            OR r.target_file = ?1
14117         ORDER BY r.ref_id",
14118    )?;
14119    let rows = stmt.query_map(params![rel_path], |row| {
14120        Ok(RefDependencyRow {
14121            ref_id: row.get(0)?,
14122            kind: row.get(1)?,
14123            caller_file: row.get(2)?,
14124            module_path: row.get(3)?,
14125            target_file: row.get(4)?,
14126        })
14127    })?;
14128    let mut ids = Vec::new();
14129    for row in rows {
14130        let row = row?;
14131        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
14132            ids.push(DependentRefSelection {
14133                ref_id: row.ref_id,
14134                caller_file: row.caller_file,
14135            });
14136        }
14137    }
14138    Ok(ids)
14139}
14140
14141fn record_dependent_refs(
14142    selected_ref_ids: &mut BTreeSet<String>,
14143    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
14144    dependent_refs: Vec<DependentRefSelection>,
14145) {
14146    for dependent_ref in dependent_refs {
14147        let DependentRefSelection {
14148            ref_id,
14149            caller_file,
14150        } = dependent_ref;
14151        selected_ref_ids.insert(ref_id.clone());
14152        selected_refs_by_caller
14153            .entry(caller_file)
14154            .or_default()
14155            .insert(ref_id);
14156    }
14157}
14158
14159#[cfg(test)]
14160fn refs_by_caller_for_ref_ids(
14161    tx: &Transaction<'_>,
14162    ref_ids: &BTreeSet<String>,
14163) -> Result<BTreeMap<String, BTreeSet<String>>> {
14164    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
14165    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
14166    for ref_id in ref_ids {
14167        if let Some(caller) = stmt
14168            .query_row(params![ref_id], |row| row.get::<_, String>(0))
14169            .optional()?
14170        {
14171            by_caller.entry(caller).or_default().insert(ref_id.clone());
14172        }
14173    }
14174    Ok(by_caller)
14175}
14176
14177fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14178    tx.execute(
14179        "DELETE FROM file_dependencies WHERE file_path = ?1",
14180        params![rel_path],
14181    )?;
14182    delete_refs_for_caller(tx, rel_path)?;
14183    tx.execute(
14184        "DELETE FROM dispatch_hints WHERE file = ?1",
14185        params![rel_path],
14186    )?;
14187    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
14188    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
14189    Ok(())
14190}
14191
14192fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14193    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
14194    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
14195    let mut ids = BTreeSet::new();
14196    for row in rows {
14197        ids.insert(row?);
14198    }
14199    delete_ref_ids(tx, &ids)
14200}
14201
14202fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
14203    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
14204    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
14205    for ref_id in ref_ids {
14206        delete_edges.execute(params![ref_id])?;
14207        delete_refs.execute(params![ref_id])?;
14208    }
14209    Ok(())
14210}
14211
14212fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
14213    let mut stmt = conn.prepare(
14214        "SELECT source.file_path, source.scoped_name, edges.target_file,
14215                edges.target_symbol, edges.kind, edges.line
14216         FROM edges
14217         JOIN nodes AS source ON source.id = edges.source_node
14218         ORDER BY source.file_path, source.scoped_name, edges.target_file,
14219                  edges.target_symbol, edges.kind, edges.line",
14220    )?;
14221    let rows = stmt.query_map([], |row| {
14222        Ok(StoredEdge {
14223            source_file: row.get(0)?,
14224            source_symbol: row.get(1)?,
14225            target_file: row.get(2)?,
14226            target_symbol: row.get(3)?,
14227            kind: row.get(4)?,
14228            line: row.get::<_, i64>(5)? as u32,
14229        })
14230    })?;
14231    let mut edges = BTreeSet::new();
14232    for row in rows {
14233        edges.insert(row?);
14234    }
14235    Ok(edges)
14236}
14237
14238fn module_target_from_dependencies(
14239    project_root: &Path,
14240    dependencies: &BTreeSet<String>,
14241    facts: &FactPaths<'_>,
14242) -> Option<String> {
14243    dependencies.iter().find_map(|dep| {
14244        let path = project_root.join(dep);
14245        if facts.is_file(&path) {
14246            Some(relative_path(
14247                project_root,
14248                &facts.canonical(&path).unwrap_or(path.clone()),
14249            ))
14250        } else {
14251            None
14252        }
14253    })
14254}
14255
14256fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
14257    let mut named = HashMap::new();
14258    if let Some(full_ref) = &raw_ref.full_ref {
14259        named = parse_reexport_names(full_ref);
14260    }
14261    ReexportIndex {
14262        target_file,
14263        named,
14264        wildcard: raw_ref.wildcard,
14265    }
14266}
14267
14268fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
14269    let mut names = HashMap::new();
14270    let Some(open) = statement.find('{') else {
14271        return names;
14272    };
14273    let Some(close) = statement[open + 1..]
14274        .find('}')
14275        .map(|offset| open + 1 + offset)
14276    else {
14277        return names;
14278    };
14279    for spec in statement[open + 1..close].split(',') {
14280        let spec = spec.trim();
14281        if spec.is_empty() {
14282            continue;
14283        }
14284        if let Some((source, local)) = spec.split_once(" as ") {
14285            names.insert(local.trim().to_string(), source.trim().to_string());
14286        } else {
14287            names.insert(spec.to_string(), spec.to_string());
14288        }
14289    }
14290    names
14291}
14292
14293#[derive(Debug)]
14294struct RefDependencyRow {
14295    ref_id: String,
14296    kind: String,
14297    caller_file: String,
14298    module_path: Option<String>,
14299    target_file: Option<String>,
14300}
14301
14302fn ref_dependency_row_depends_on(
14303    project_root: &Path,
14304    row: &RefDependencyRow,
14305    rel_path: &str,
14306) -> bool {
14307    if row.target_file.as_deref() == Some(rel_path) {
14308        return true;
14309    }
14310
14311    match row.kind.as_str() {
14312        "call" => true,
14313        "import" | "reexport" => row
14314            .module_path
14315            .as_deref()
14316            .map(|module_path| {
14317                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
14318                    .contains(rel_path)
14319            })
14320            .unwrap_or(false),
14321        "export_alias" => false,
14322        _ => false,
14323    }
14324}
14325
14326fn module_dependencies_for_ref(
14327    project_root: &Path,
14328    caller_file: &str,
14329    module_path: &str,
14330) -> BTreeSet<String> {
14331    module_dependencies(
14332        project_root,
14333        &project_root.join(caller_file),
14334        module_path,
14335        &FactPaths {
14336            root: project_root,
14337            facts: &DiskFacts::new(project_root),
14338        },
14339    )
14340}
14341
14342fn import_dependencies(
14343    project_root: &Path,
14344    abs_path: &Path,
14345    imports: &[ImportStatement],
14346    facts: &FactPaths<'_>,
14347) -> BTreeSet<String> {
14348    let mut deps = BTreeSet::new();
14349    for import in imports {
14350        deps.extend(module_dependencies(
14351            project_root,
14352            abs_path,
14353            &import.module_path,
14354            facts,
14355        ));
14356    }
14357    deps
14358}
14359
14360fn module_dependencies(
14361    project_root: &Path,
14362    abs_path: &Path,
14363    module_path: &str,
14364    facts: &FactPaths<'_>,
14365) -> BTreeSet<String> {
14366    let mut deps = rust_module_dependencies(project_root, abs_path, module_path, facts);
14367    let caller_dir = abs_path.parent().unwrap_or(project_root);
14368    if let Some(resolved) = callgraph::resolve_module_path_with_memo(
14369        caller_dir,
14370        module_path,
14371        &callgraph::ModuleResolutionMemo::default(),
14372        facts,
14373    ) {
14374        deps.insert(relative_path(project_root, &resolved));
14375    }
14376    if module_path.starts_with('.') {
14377        let base = caller_dir.join(module_path);
14378        for candidate in relative_module_candidates(&base) {
14379            deps.insert(relative_path(project_root, &candidate));
14380        }
14381    }
14382    deps
14383}
14384
14385fn rust_module_dependencies(
14386    project_root: &Path,
14387    abs_path: &Path,
14388    module_path: &str,
14389    facts: &FactPaths<'_>,
14390) -> BTreeSet<String> {
14391    let mut deps = BTreeSet::new();
14392    let rel_path = relative_path(
14393        project_root,
14394        &facts
14395            .canonical(abs_path)
14396            .unwrap_or_else(|| abs_path.to_path_buf()),
14397    );
14398    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
14399        return deps;
14400    };
14401    let src_prefix = rust_src_prefix(&rel_path);
14402    rust_push_module_dependency_candidate(
14403        project_root,
14404        &mut deps,
14405        &src_prefix,
14406        &path_segments,
14407        facts,
14408    );
14409    if !path_segments.is_empty() {
14410        rust_push_module_dependency_candidate(
14411            project_root,
14412            &mut deps,
14413            &src_prefix,
14414            &path_segments[..path_segments.len() - 1],
14415            facts,
14416        );
14417    }
14418    deps
14419}
14420
14421fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
14422    let path = rust_module_path_without_alias_or_use_list(module_path);
14423    let segments = path
14424        .split("::")
14425        .map(str::trim)
14426        .filter(|segment| !segment.is_empty())
14427        .collect::<Vec<_>>();
14428    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
14429        return None;
14430    }
14431    rust_resolve_segments(rel_path, &segments)
14432}
14433
14434fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
14435    let path = module_path
14436        .trim()
14437        .trim_end_matches(';')
14438        .split_once(" as ")
14439        .map(|(left, _)| left.trim())
14440        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
14441    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
14442}
14443
14444fn rust_push_module_dependency_candidate(
14445    project_root: &Path,
14446    deps: &mut BTreeSet<String>,
14447    src_prefix: &str,
14448    segments: &[String],
14449    facts: &FactPaths<'_>,
14450) {
14451    let candidates = if segments.is_empty() {
14452        vec![
14453            format!("{src_prefix}/lib.rs"),
14454            format!("{src_prefix}/main.rs"),
14455        ]
14456    } else {
14457        vec![
14458            format!("{}/{}.rs", src_prefix, segments.join("/")),
14459            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
14460        ]
14461    };
14462    for candidate in candidates {
14463        if facts.is_file(&project_root.join(&candidate)) {
14464            deps.insert(candidate);
14465        }
14466    }
14467}
14468
14469fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
14470    let mut candidates = Vec::new();
14471    if base.extension().is_some() {
14472        candidates.push(base.to_path_buf());
14473        return candidates;
14474    }
14475    for ext in JS_TS_EXTENSIONS {
14476        candidates.push(base.with_extension(ext));
14477    }
14478    for ext in JS_TS_EXTENSIONS {
14479        candidates.push(base.join(format!("index.{ext}")));
14480    }
14481    candidates
14482}
14483
14484fn import_local_names(import: &ImportStatement) -> Vec<String> {
14485    let mut names = Vec::new();
14486    if let Some(default) = &import.default_import {
14487        names.push(default.clone());
14488    }
14489    if let Some(namespace) = &import.namespace_import {
14490        names.push(namespace.clone());
14491    }
14492    for name in &import.names {
14493        names.push(crate::imports::specifier_local_name(name).to_string());
14494    }
14495    names
14496}
14497
14498fn import_requested_names(import: &ImportStatement) -> Vec<String> {
14499    import
14500        .names
14501        .iter()
14502        .map(|name| crate::imports::specifier_imported_name(name).to_string())
14503        .collect()
14504}
14505
14506fn import_is_wildcard(import: &ImportStatement) -> bool {
14507    import.namespace_import.is_some() || import.raw_text.contains('*')
14508}
14509
14510fn namespace_alias(full_ref: &str) -> Option<String> {
14511    full_ref
14512        .split_once('.')
14513        .map(|(namespace, _)| namespace.to_string())
14514}
14515
14516fn import_kind_label(kind: ImportKind) -> &'static str {
14517    match kind {
14518        ImportKind::Value => "value",
14519        ImportKind::Type => "type",
14520        ImportKind::SideEffect => "side_effect",
14521    }
14522}
14523
14524fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
14525    match kind {
14526        SymbolKind::Function => "function",
14527        SymbolKind::Kernel => "kernel",
14528        SymbolKind::Class => "class",
14529        SymbolKind::Method => "method",
14530        SymbolKind::Struct => "struct",
14531        SymbolKind::Interface => "interface",
14532        SymbolKind::Enum => "enum",
14533        SymbolKind::TypeAlias => "type_alias",
14534        SymbolKind::Variable => "variable",
14535        SymbolKind::Heading => "heading",
14536        SymbolKind::FileSummary => "file_summary",
14537    }
14538}
14539
14540fn is_type_like(kind: &SymbolKind) -> bool {
14541    matches!(
14542        kind,
14543        SymbolKind::Class
14544            | SymbolKind::Struct
14545            | SymbolKind::Interface
14546            | SymbolKind::Enum
14547            | SymbolKind::TypeAlias
14548    )
14549}
14550
14551fn lang_label(lang: LangId) -> &'static str {
14552    match lang {
14553        LangId::TypeScript => "typescript",
14554        LangId::Tsx => "tsx",
14555        LangId::JavaScript => "javascript",
14556        LangId::Python => "python",
14557        LangId::Rust => "rust",
14558        LangId::Go => "go",
14559        LangId::C => "c",
14560        LangId::Cpp => "cpp",
14561        LangId::Cuda => "cuda",
14562        LangId::Metal => "metal",
14563        LangId::Zig => "zig",
14564        LangId::CSharp => "csharp",
14565        LangId::Bash => "bash",
14566        LangId::Html => "html",
14567        LangId::Markdown => "markdown",
14568        LangId::Solidity => "solidity",
14569        LangId::Scss => "scss",
14570        LangId::Vue => "vue",
14571        LangId::Json => "json",
14572        LangId::Scala => "scala",
14573        LangId::Java => "java",
14574        LangId::Ruby => "ruby",
14575        LangId::Kotlin => "kotlin",
14576        LangId::Swift => "swift",
14577        LangId::Php => "php",
14578        LangId::Lua => "lua",
14579        LangId::Perl => "perl",
14580        LangId::Yaml => "yaml",
14581        LangId::Pascal => "pascal",
14582        LangId::R => "r",
14583        LangId::Groovy => "groovy",
14584        LangId::ObjC => "objc",
14585        LangId::Toml => "toml",
14586    }
14587}
14588
14589fn lang_from_label(label: &str) -> Option<LangId> {
14590    match label {
14591        "typescript" => Some(LangId::TypeScript),
14592        "tsx" => Some(LangId::Tsx),
14593        "javascript" => Some(LangId::JavaScript),
14594        "python" => Some(LangId::Python),
14595        "rust" => Some(LangId::Rust),
14596        "go" => Some(LangId::Go),
14597        "c" => Some(LangId::C),
14598        "cpp" => Some(LangId::Cpp),
14599        "cuda" => Some(LangId::Cuda),
14600        "metal" => Some(LangId::Metal),
14601        "zig" => Some(LangId::Zig),
14602        "csharp" => Some(LangId::CSharp),
14603        "bash" => Some(LangId::Bash),
14604        "html" => Some(LangId::Html),
14605        "markdown" => Some(LangId::Markdown),
14606        "solidity" => Some(LangId::Solidity),
14607        "scss" => Some(LangId::Scss),
14608        "vue" => Some(LangId::Vue),
14609        "json" => Some(LangId::Json),
14610        "scala" => Some(LangId::Scala),
14611        "java" => Some(LangId::Java),
14612        "ruby" => Some(LangId::Ruby),
14613        "kotlin" => Some(LangId::Kotlin),
14614        "swift" => Some(LangId::Swift),
14615        "php" => Some(LangId::Php),
14616        "lua" => Some(LangId::Lua),
14617        "perl" => Some(LangId::Perl),
14618        "yaml" => Some(LangId::Yaml),
14619        "pascal" => Some(LangId::Pascal),
14620        "r" => Some(LangId::R),
14621        "groovy" => Some(LangId::Groovy),
14622        "objc" => Some(LangId::ObjC),
14623        "toml" => Some(LangId::Toml),
14624        _ => None,
14625    }
14626}
14627
14628fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
14629    let mut normalized = if files.is_empty() {
14630        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
14631    } else {
14632        files
14633            .iter()
14634            .map(|path| normalize_file_path(project_root, path))
14635            .collect::<Result<Vec<_>>>()?
14636    };
14637    normalized.sort();
14638    normalized.dedup();
14639    Ok(normalized)
14640}
14641
14642fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
14643    let full_path = if path.is_relative() {
14644        project_root.join(path)
14645    } else {
14646        path.to_path_buf()
14647    };
14648    Ok(canonicalize_path(&full_path))
14649}
14650
14651/// Normalize a refresh path against the store root before assigning its durable
14652/// relative key. Deleted watcher paths need lenient canonicalization: their
14653/// parent can still reveal an alias such as a symlinked project root.
14654fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
14655    let abs_path = normalize_file_path(project_root, path)?;
14656    let rel_path = relative_path(project_root, &abs_path);
14657    if Path::new(&rel_path).is_absolute() {
14658        return Err(CallGraphStoreError::PathIdentityMismatch {
14659            path: path.to_path_buf(),
14660            project_root: project_root.to_path_buf(),
14661        });
14662    }
14663    Ok((abs_path, rel_path))
14664}
14665
14666/// Canonicalize an existing path or the deepest existing ancestor of a deleted
14667/// one. This keeps watcher deletion events in the same identity domain as the
14668/// files indexed before the deletion.
14669fn canonicalize_path(path: &Path) -> PathBuf {
14670    if let Ok(canonical) = std::fs::canonicalize(path) {
14671        return canonical;
14672    }
14673
14674    let mut resolved = PathBuf::new();
14675    let mut missing = Vec::new();
14676    for component in path.components() {
14677        match component {
14678            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
14679                resolved.push(component.as_os_str());
14680                if let Ok(canonical) = std::fs::canonicalize(&resolved) {
14681                    resolved = canonical;
14682                }
14683            }
14684            std::path::Component::CurDir => {}
14685            std::path::Component::ParentDir => {
14686                if missing.pop().is_none() {
14687                    if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
14688                        return path.to_path_buf();
14689                    }
14690                    resolved.pop();
14691                }
14692            }
14693            std::path::Component::Normal(name) => {
14694                if missing.is_empty() {
14695                    let candidate = resolved.join(name);
14696                    match std::fs::canonicalize(&candidate) {
14697                        Ok(canonical) => resolved = canonical,
14698                        Err(_) => match std::fs::symlink_metadata(&candidate) {
14699                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14700                                missing.push(name.to_owned());
14701                            }
14702                            _ => return path.to_path_buf(),
14703                        },
14704                    }
14705                } else {
14706                    missing.push(name.to_owned());
14707                }
14708            }
14709        }
14710    }
14711    resolved.extend(missing);
14712    resolved
14713}
14714
14715fn relative_path(project_root: &Path, path: &Path) -> String {
14716    if let Ok(stripped) = path.strip_prefix(project_root) {
14717        return stripped.to_string_lossy().replace('\\', "/");
14718    }
14719    let canon_root = canonicalize_path(project_root);
14720    let canon_path = canonicalize_path(path);
14721    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
14722        return stripped.to_string_lossy().replace('\\', "/");
14723    }
14724    canon_path.to_string_lossy().replace('\\', "/")
14725}
14726
14727fn unqualified_name(scoped: &str) -> &str {
14728    if scoped == TOP_LEVEL_SYMBOL {
14729        return scoped;
14730    }
14731    scoped
14732        .rsplit("::")
14733        .next()
14734        .unwrap_or(scoped)
14735        .rsplit('.')
14736        .next()
14737        .unwrap_or(scoped)
14738        .rsplit('#')
14739        .next()
14740        .unwrap_or(scoped)
14741}
14742
14743fn ref_id(parts: &[&str]) -> String {
14744    let joined = parts.join("\0");
14745    hash_to_hex(blake3::hash(joined.as_bytes()))
14746}
14747
14748fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
14749    let mut fingerprint = CorpusFingerprint::default();
14750    for path in callgraph::walk_project_files(project_root) {
14751        fingerprint.add_path(project_root, &path);
14752    }
14753    Ok(fingerprint.finish(project_root))
14754}
14755
14756/// Pre-admission fingerprint over the same source set the staging inventory
14757/// will consume: the walk when no explicit list is supplied, the list
14758/// otherwise. Streaming accumulator - no staging writes, bounded memory.
14759fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
14760    if files.is_empty() {
14761        return callgraph_corpus_fingerprint(project_root);
14762    }
14763    let mut fingerprint = CorpusFingerprint::default();
14764    for path in files {
14765        fingerprint.add_path(project_root, path);
14766    }
14767    Ok(fingerprint.finish(project_root))
14768}
14769
14770#[derive(Default)]
14771struct CorpusFingerprint {
14772    xor: [u8; 32],
14773    sums: [u64; 4],
14774    files: u64,
14775}
14776
14777impl CorpusFingerprint {
14778    fn add_path(&mut self, project_root: &Path, path: &Path) {
14779        let mut record = blake3::Hasher::new();
14780        record.update(relative_path(project_root, path).as_bytes());
14781        record.update(&[0]);
14782        match hash_file_bounded(path) {
14783            Ok(content_hash) => record.update(content_hash.as_bytes()),
14784            // Encoding a missing file as a distinct record changes the corpus
14785            // fingerprint, so breaker state keyed to the previous corpus is not reused.
14786            Err(error) => record.update(format!("missing:{error}").as_bytes()),
14787        };
14788        record.update(&[0]);
14789        let record = record.finalize();
14790        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14791            self.xor[index] ^= byte;
14792        }
14793        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14794            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14795            self.sums[index] = self.sums[index].wrapping_add(value);
14796        }
14797        self.files = self.files.saturating_add(1);
14798    }
14799
14800    fn finish(self, project_root: &Path) -> String {
14801        // Combining both xor and modular sums keeps the digest independent of
14802        // walk order while retaining duplicate sensitivity for generic callers.
14803        let mut hasher = blake3::Hasher::new();
14804        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14805        hasher.update(&self.files.to_le_bytes());
14806        hasher.update(&self.xor);
14807        for sum in self.sums {
14808            hasher.update(&sum.to_le_bytes());
14809        }
14810        let ignore_rules = project_root.join(".gitignore");
14811        if let Ok(contents) = std::fs::read(ignore_rules) {
14812            hasher.update(b".gitignore\0");
14813            hasher.update(blake3::hash(&contents).as_bytes());
14814        }
14815        hash_to_hex(hasher.finalize())
14816    }
14817}
14818
14819fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14820    let mut file = std::fs::File::open(path)?;
14821    let mut hasher = blake3::Hasher::new();
14822    let mut buffer = [0u8; 64 * 1024];
14823    loop {
14824        let read = file.read(&mut buffer)?;
14825        if read == 0 {
14826            break;
14827        }
14828        hasher.update(&buffer[..read]);
14829    }
14830    Ok(hasher.finalize())
14831}
14832
14833#[cfg(test)]
14834pub(crate) fn callgraph_corpus_fingerprint_for_test(
14835    project_root: &Path,
14836    _files: &[PathBuf],
14837) -> Result<String> {
14838    // The streaming fingerprint walks the corpus itself (order-independent
14839    // accumulator, no resident file list); the test seam keeps its historical
14840    // signature so callers need not thread a walk of their own.
14841    callgraph_corpus_fingerprint(project_root)
14842}
14843
14844fn hash_to_hex(hash: blake3::Hash) -> String {
14845    hash.to_hex().to_string()
14846}
14847
14848fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14849    let bytes = hex_to_bytes(value)?;
14850    Some(blake3::Hash::from_bytes(bytes))
14851}
14852
14853fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14854    if value.len() != 64 {
14855        return None;
14856    }
14857    let mut bytes = [0u8; 32];
14858    for (index, slot) in bytes.iter_mut().enumerate() {
14859        let start = index * 2;
14860        let end = start + 2;
14861        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14862    }
14863    Some(bytes)
14864}
14865
14866#[derive(Debug, Clone)]
14867struct LineIndex {
14868    newline_offsets: Vec<usize>,
14869    source_len: usize,
14870}
14871
14872impl LineIndex {
14873    fn new(source: &str) -> Self {
14874        Self {
14875            newline_offsets: source
14876                .bytes()
14877                .enumerate()
14878                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14879                .collect(),
14880            source_len: source.len(),
14881        }
14882    }
14883
14884    fn byte_to_line(&self, byte_offset: usize) -> u32 {
14885        let byte_offset = byte_offset.min(self.source_len);
14886        self.newline_offsets
14887            .partition_point(|offset| *offset < byte_offset) as u32
14888            + 1
14889    }
14890}
14891
14892fn empty_to_none(value: String) -> Option<String> {
14893    if value.is_empty() {
14894        None
14895    } else {
14896        Some(value)
14897    }
14898}
14899
14900fn bool_int(value: bool) -> i64 {
14901    if value {
14902        1
14903    } else {
14904        0
14905    }
14906}
14907
14908fn system_time_to_ns(time: SystemTime) -> i64 {
14909    time.duration_since(UNIX_EPOCH)
14910        .unwrap_or_default()
14911        .as_nanos()
14912        .min(i64::MAX as u128) as i64
14913}
14914
14915fn ns_to_system_time(value: i64) -> SystemTime {
14916    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14917}
14918
14919pub(crate) fn unix_millis_now() -> u64 {
14920    SystemTime::now()
14921        .duration_since(UNIX_EPOCH)
14922        .unwrap_or_default()
14923        .as_millis()
14924        .min(u128::from(u64::MAX)) as u64
14925}
14926
14927fn unix_seconds_now() -> i64 {
14928    SystemTime::now()
14929        .duration_since(UNIX_EPOCH)
14930        .unwrap_or_default()
14931        .as_secs() as i64
14932}
14933
14934/// Serializes every test that drives the process-wide refresh worker
14935/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
14936/// worker down between another test's enqueue and its flush, deferring the
14937/// batch and zeroing that test's seam counts).
14938#[cfg(test)]
14939pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14940
14941#[cfg(test)]
14942mod refresh_worker_tests {
14943    use super::*;
14944    use std::fs;
14945    use tempfile::tempdir;
14946
14947    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14948        let temp = tempdir().unwrap();
14949        let root = temp.path().join("root");
14950        fs::create_dir_all(&root).unwrap();
14951        let artifact_key = crate::search_index::artifact_cache_key(&root);
14952        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14953        let callgraph_dir = temp
14954            .path()
14955            .join("storage")
14956            .join("callgraph")
14957            .join(artifact_key);
14958        let source = root.join("main.rs");
14959        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14960        let (store, _) = CallGraphStore::cold_build_with_lease(
14961            callgraph_dir.clone(),
14962            root.clone(),
14963            std::slice::from_ref(&source),
14964        )
14965        .unwrap();
14966        drop(store);
14967        (temp, root, callgraph_dir, source)
14968    }
14969
14970    fn pending_paths() -> PendingCallGraphStorePaths {
14971        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14972    }
14973
14974    fn wait_for_refresh_calls(root: &Path, expected: usize) {
14975        let deadline = Instant::now() + Duration::from_secs(12);
14976        while callgraph_refresh_worker_test_counts(root).0 < expected {
14977            assert!(
14978                Instant::now() < deadline,
14979                "timed out waiting for {expected} callgraph refresh worker call(s)"
14980            );
14981            std::thread::sleep(Duration::from_millis(5));
14982        }
14983    }
14984
14985    fn wait_for_refresh_worker_idle() {
14986        let deadline = Instant::now() + Duration::from_secs(12);
14987        loop {
14988            let worker = CALLGRAPH_REFRESH_WORKER
14989                .get_or_init(|| Mutex::new(None))
14990                .lock()
14991                .expect("callgraph refresh worker mutex poisoned")
14992                .clone();
14993            let idle = worker.is_none_or(|worker| {
14994                let queue = worker
14995                    .shared
14996                    .queue
14997                    .lock()
14998                    .expect("callgraph refresh queue mutex poisoned");
14999                queue.active.is_none() && queue.order.is_empty()
15000            });
15001            if idle {
15002                return;
15003            }
15004            assert!(
15005                Instant::now() < deadline,
15006                "timed out waiting for callgraph refresh worker to become idle"
15007            );
15008            std::thread::sleep(Duration::from_millis(5));
15009        }
15010    }
15011
15012    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
15013        let temp = tempdir().unwrap();
15014        let root = temp.path().join("workspace");
15015        fs::create_dir_all(root.join("app/src")).unwrap();
15016        let artifact_key = crate::search_index::artifact_cache_key(&root);
15017        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
15018        let callgraph_dir = temp
15019            .path()
15020            .join("storage")
15021            .join("callgraph")
15022            .join(artifact_key);
15023        fs::write(
15024            root.join("Cargo.toml"),
15025            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
15026        )
15027        .unwrap();
15028        fs::write(
15029            root.join("app/Cargo.toml"),
15030            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
15031        )
15032        .unwrap();
15033        let caller = root.join("app/src/lib.rs");
15034        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
15035        let (store, _) = CallGraphStore::cold_build_with_lease(
15036            callgraph_dir.clone(),
15037            root.clone(),
15038            std::slice::from_ref(&caller),
15039        )
15040        .unwrap();
15041        drop(store);
15042        (temp, root, callgraph_dir, caller)
15043    }
15044
15045    #[test]
15046    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
15047        let _guard = REFRESH_WORKER_TEST_LOCK
15048            .lock()
15049            .unwrap_or_else(std::sync::PoisonError::into_inner);
15050        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15051        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
15052        reset_workspace_crate_prefix_build_count(&root);
15053        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15054
15055        for revision in ["first", "second"] {
15056            fs::write(
15057                &caller,
15058                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
15059            )
15060            .unwrap();
15061            enqueue_callgraph_store_refresh(
15062                callgraph_dir.clone(),
15063                root.clone(),
15064                vec![caller.clone()],
15065                pending_paths(),
15066            );
15067            wait_for_refresh_worker_idle();
15068        }
15069
15070        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
15071        assert!(flush_callgraph_store_refreshes_with_budget(
15072            Duration::from_secs(5)
15073        ));
15074        clear_callgraph_refresh_worker_test_seam(&root);
15075    }
15076
15077    #[test]
15078    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
15079        let _guard = REFRESH_WORKER_TEST_LOCK
15080            .lock()
15081            .unwrap_or_else(std::sync::PoisonError::into_inner);
15082        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15083        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
15084        reset_workspace_crate_prefix_build_count(&root);
15085        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15086
15087        fs::write(
15088            &caller,
15089            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
15090        )
15091        .unwrap();
15092        enqueue_callgraph_store_refresh(
15093            callgraph_dir.clone(),
15094            root.clone(),
15095            vec![caller.clone()],
15096            pending_paths(),
15097        );
15098        wait_for_refresh_worker_idle();
15099        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
15100
15101        let added_manifest = root.join("added/Cargo.toml");
15102        let added_source = root.join("added/src/lib.rs");
15103        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
15104        fs::write(
15105            root.join("Cargo.toml"),
15106            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
15107        )
15108        .unwrap();
15109        fs::write(
15110            &added_manifest,
15111            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
15112        )
15113        .unwrap();
15114        fs::write(&added_source, "pub fn target() {}\n").unwrap();
15115        fs::write(
15116            &caller,
15117            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
15118        )
15119        .unwrap();
15120
15121        enqueue_callgraph_store_refresh(
15122            callgraph_dir.clone(),
15123            root.clone(),
15124            vec![
15125                root.join("Cargo.toml"),
15126                added_manifest,
15127                added_source,
15128                caller,
15129            ],
15130            pending_paths(),
15131        );
15132        assert!(flush_callgraph_store_refreshes_with_budget(
15133            Duration::from_secs(12)
15134        ));
15135
15136        // This is the negative control for a permanently-static cache: without
15137        // manifest invalidation the build count stays at one and the call remains
15138        // unresolved because `added_crate` was absent when the map was primed.
15139        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
15140        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15141            .unwrap()
15142            .expect("refreshed workspace store");
15143        let tree = store
15144            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
15145            .unwrap();
15146        assert_eq!(tree.children.len(), 1);
15147        assert_eq!(tree.children[0].file, "added/src/lib.rs");
15148        assert_eq!(tree.children[0].name, "target");
15149        assert!(tree.children[0].resolved);
15150        clear_callgraph_refresh_worker_test_seam(&root);
15151    }
15152
15153    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
15154        let temp = tempdir().unwrap();
15155        let main = temp.path().join("main");
15156        let worktree = temp.path().join("worktree");
15157        fs::create_dir_all(&main).unwrap();
15158        let mut git = std::process::Command::new("git");
15159        assert!(
15160            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
15161                .status()
15162                .unwrap()
15163                .success()
15164        );
15165        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
15166        for args in [
15167            vec![
15168                "-C",
15169                main.to_str().unwrap(),
15170                "config",
15171                "user.email",
15172                "test@example.com",
15173            ],
15174            vec![
15175                "-C",
15176                main.to_str().unwrap(),
15177                "config",
15178                "user.name",
15179                "AFT Test",
15180            ],
15181            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
15182            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
15183        ] {
15184            let mut command = std::process::Command::new("git");
15185            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
15186                .status()
15187                .unwrap()
15188                .success());
15189        }
15190        let mut add_worktree = std::process::Command::new("git");
15191        assert!(crate::test_env::apply_hermetic_git_env(
15192            add_worktree
15193                .arg("-C")
15194                .arg(&main)
15195                .args(["worktree", "add", "--detach"])
15196                .arg(&worktree),
15197        )
15198        .status()
15199        .unwrap()
15200        .success());
15201        let main = fs::canonicalize(main).unwrap();
15202        let worktree = fs::canonicalize(worktree).unwrap();
15203        let project_key = crate::search_index::artifact_cache_key(&main);
15204        assert_eq!(
15205            crate::search_index::artifact_cache_key(&worktree),
15206            project_key
15207        );
15208        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15209        (temp, main, worktree, project_key, callgraph_dir)
15210    }
15211
15212    #[test]
15213    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
15214        let _git_env = crate::test_env::hermetic_git_env_guard();
15215        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
15216        crate::root_cache::configure_artifact_access(&root, &project_key, true);
15217        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15218        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15219        let publications_for_observer = Arc::clone(&publications);
15220        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15221            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15222        })));
15223        let source = root.join("lib.rs");
15224
15225        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
15226            .expect_err("borrow-only writable open must remain unavailable");
15227        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
15228        assert!(
15229            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
15230                .unwrap()
15231                .is_none()
15232        );
15233        assert!(
15234            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
15235                .unwrap()
15236                .is_none()
15237        );
15238        assert!(matches!(
15239            CallGraphStore::cold_build_with_lease(
15240                callgraph_dir.clone(),
15241                root.clone(),
15242                std::slice::from_ref(&source),
15243            ),
15244            Err(CallGraphStoreError::Unavailable(_))
15245        ));
15246        assert!(matches!(
15247            CallGraphStore::ensure_built_with_lease(
15248                callgraph_dir.clone(),
15249                root.clone(),
15250                std::slice::from_ref(&source),
15251            ),
15252            Err(CallGraphStoreError::Unavailable(_))
15253        ));
15254        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
15255            callgraph_dir.clone(),
15256            root.clone(),
15257            &[source],
15258            1,
15259        )
15260        .expect_err("borrow-only forced rebuild must remain unsatisfied");
15261        set_cold_build_swap_observer(None);
15262
15263        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
15264        assert_eq!(
15265            crate::root_cache::writer_lease_acquisition_count_for_test(
15266                crate::root_cache::RootCacheDomain::Callgraph,
15267                &project_key,
15268                &root,
15269            ),
15270            0
15271        );
15272        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
15273        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
15274    }
15275
15276    #[test]
15277    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
15278        let _git_env = crate::test_env::hermetic_git_env_guard();
15279        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
15280        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
15281        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
15282        let source = owner.join("lib.rs");
15283        let (store, _) = CallGraphStore::cold_build_with_lease(
15284            callgraph_dir.clone(),
15285            owner.clone(),
15286            std::slice::from_ref(&source),
15287        )
15288        .unwrap();
15289        let sqlite_path = store.sqlite_path().to_path_buf();
15290        drop(store);
15291
15292        let conn = Connection::open(&sqlite_path).unwrap();
15293        conn.execute(
15294            "UPDATE backend_file_state SET workspace_root = ?1",
15295            [worktree.display().to_string()],
15296        )
15297        .unwrap();
15298        drop(conn);
15299
15300        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15301        let publications_for_observer = Arc::clone(&publications);
15302        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15303            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15304        })));
15305        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15306
15307        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15308            .unwrap()
15309            .expect("owner should purge the storm-era worktree root");
15310        drop(repaired);
15311        for _ in 0..3 {
15312            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
15313                .unwrap()
15314                .expect("linked worktree should borrow the owner generation");
15315            drop(borrower);
15316            assert!(
15317                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
15318                    .unwrap()
15319                    .is_none()
15320            );
15321            let owner_store =
15322                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15323                    .unwrap()
15324                    .expect("owner generation should remain ready");
15325            drop(owner_store);
15326        }
15327        set_cold_build_swap_observer(None);
15328
15329        assert_eq!(
15330            publications.load(AtomicOrdering::SeqCst),
15331            1,
15332            "the owner performs one expected post-storm purge and alternation stays read-only"
15333        );
15334        assert_eq!(
15335            crate::root_cache::writer_lease_acquisition_count_for_test(
15336                crate::root_cache::RootCacheDomain::Callgraph,
15337                &project_key,
15338                &worktree,
15339            ),
15340            0
15341        );
15342    }
15343
15344    #[test]
15345    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
15346        let temp = tempdir().unwrap();
15347        let root = temp.path().join("owner");
15348        let other_root = temp.path().join("other");
15349        fs::create_dir_all(&root).unwrap();
15350        fs::create_dir_all(&other_root).unwrap();
15351        let source = root.join("lib.rs");
15352        fs::write(&source, "pub fn marker() {}\n").unwrap();
15353        let project_key = crate::search_index::artifact_cache_key(&root);
15354        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15355        crate::root_cache::configure_artifact_access(&root, &project_key, false);
15356        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
15357        rebuild_cooldown_records()
15358            .lock()
15359            .unwrap_or_else(std::sync::PoisonError::into_inner)
15360            .remove(&cooldown_key);
15361        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15362        let stale_epoch = epoch.current();
15363        epoch.next();
15364
15365        let failed = with_publish_epoch(epoch, stale_epoch, || {
15366            CallGraphStore::cold_build_with_lease(
15367                callgraph_dir.clone(),
15368                root.clone(),
15369                std::slice::from_ref(&source),
15370            )
15371        });
15372        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
15373        assert!(
15374            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15375                .is_none()
15376        );
15377
15378        let (store, _) = CallGraphStore::cold_build_with_lease(
15379            callgraph_dir.clone(),
15380            root.clone(),
15381            std::slice::from_ref(&source),
15382        )
15383        .unwrap();
15384        drop(store);
15385        assert!(
15386            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15387                .is_none()
15388        );
15389
15390        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
15391        assert!(
15392            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
15393        );
15394    }
15395
15396    #[test]
15397    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
15398        let _guard = REFRESH_WORKER_TEST_LOCK
15399            .lock()
15400            .unwrap_or_else(std::sync::PoisonError::into_inner);
15401        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15402        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15403        let pending = pending_paths();
15404        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15405
15406        let lifecycle = SubcLifecycleAdmission::default();
15407        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15408        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15409        let ticket = CallgraphRefreshTicket::new(
15410            lifecycle,
15411            Arc::clone(&generation),
15412            7,
15413            publish_epoch.clone(),
15414            publish_epoch.current(),
15415        );
15416        // Supersede before the worker runs: the batch must defer, not commit.
15417        generation.store(8, std::sync::atomic::Ordering::SeqCst);
15418        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15419            .unwrap()
15420            .expect("ready store snapshot");
15421        let refresh_state = CallgraphRefreshState::new(
15422            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
15423            Arc::new(AtomicBool::new(true)),
15424        );
15425
15426        enqueue_callgraph_store_refresh_fenced_with_state(
15427            callgraph_dir,
15428            root.clone(),
15429            vec![source.clone()],
15430            Arc::clone(&pending),
15431            refresh_state,
15432            ticket,
15433        );
15434        assert!(flush_callgraph_store_refreshes_with_budget(
15435            Duration::from_secs(5)
15436        ));
15437        assert_eq!(
15438            callgraph_refresh_worker_test_counts(&root).0,
15439            0,
15440            "superseded batch must not reach refresh_files or self-replay"
15441        );
15442        assert!(
15443            pending.lock().contains(&source),
15444            "superseded batch must defer its paths to the pending sink"
15445        );
15446        clear_callgraph_refresh_worker_test_seam(&root);
15447    }
15448
15449    #[test]
15450    fn superseded_open_failure_defers_without_self_replay() {
15451        let _guard = REFRESH_WORKER_TEST_LOCK
15452            .lock()
15453            .unwrap_or_else(std::sync::PoisonError::into_inner);
15454        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15455        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15456        let pending = pending_paths();
15457        let installed = Arc::new(
15458            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15459                .unwrap()
15460                .expect("ready store snapshot"),
15461        );
15462        let refresh_state = CallgraphRefreshState::new(
15463            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
15464            Arc::new(AtomicBool::new(true)),
15465        );
15466        assert!(!installed.is_legacy_fallback());
15467        assert!(installed.is_current());
15468        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15469        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15470        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
15471        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
15472
15473        let lifecycle = SubcLifecycleAdmission::default();
15474        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15475        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15476        let ticket = CallgraphRefreshTicket::new(
15477            lifecycle,
15478            Arc::clone(&generation),
15479            7,
15480            publish_epoch.clone(),
15481            publish_epoch.current(),
15482        );
15483        enqueue_callgraph_store_refresh_fenced_with_state(
15484            callgraph_dir,
15485            root.clone(),
15486            vec![source.clone()],
15487            Arc::clone(&pending),
15488            refresh_state,
15489            ticket,
15490        );
15491        held_rx
15492            .recv_timeout(Duration::from_secs(12))
15493            .expect("refresh worker must hold after injected open failure");
15494
15495        // Mark the refresh request obsolete after the injected open failure,
15496        // then unblock the worker before its deferred retry can run.
15497        generation.store(8, std::sync::atomic::Ordering::SeqCst);
15498        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
15499        release_tx
15500            .send(())
15501            .expect("release superseded refresh worker");
15502        wait_for_refresh_worker_idle();
15503
15504        assert_eq!(
15505            callgraph_refresh_worker_test_counts(&root).0,
15506            1,
15507            "superseded open-failure batch must not self-replay"
15508        );
15509        assert_eq!(
15510            callgraph_refresh_worker_test_worker_calls(&root),
15511            1,
15512            "superseded open-failure batch must not create another worker call"
15513        );
15514        assert!(
15515            pending.lock().contains(&source),
15516            "superseded open-failure paths must remain in the pending sink"
15517        );
15518        let tree = installed
15519            .call_tree(Path::new("main.rs"), "entry", 1)
15520            .unwrap();
15521        assert_eq!(
15522            tree.children[0].name, "old_leaf",
15523            "superseded open-failure batch must not converge the store"
15524        );
15525        clear_callgraph_refresh_worker_test_seam(&root);
15526    }
15527
15528    #[test]
15529    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
15530        let _guard = REFRESH_WORKER_TEST_LOCK
15531            .lock()
15532            .unwrap_or_else(std::sync::PoisonError::into_inner);
15533        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15534        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15535        let pending = pending_paths();
15536        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15537
15538        let lifecycle = SubcLifecycleAdmission::default();
15539        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
15540        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15541        let expected_epoch = publish_epoch.current();
15542        let ticket = CallgraphRefreshTicket::new(
15543            lifecycle,
15544            generation,
15545            3,
15546            publish_epoch.clone(),
15547            expected_epoch,
15548        );
15549        // A cold build published a replacement generation after enqueue.
15550        publish_epoch.next();
15551
15552        enqueue_callgraph_store_refresh_fenced(
15553            callgraph_dir,
15554            root.clone(),
15555            vec![source.clone()],
15556            Arc::clone(&pending),
15557            ticket,
15558        );
15559        assert!(flush_callgraph_store_refreshes_with_budget(
15560            Duration::from_secs(5)
15561        ));
15562        assert_eq!(
15563            callgraph_refresh_worker_test_counts(&root).0,
15564            0,
15565            "epoch-superseded batch must not reach refresh_files"
15566        );
15567        assert!(
15568            pending.lock().contains(&source),
15569            "epoch-superseded batch must defer its paths to the pending sink"
15570        );
15571        clear_callgraph_refresh_worker_test_seam(&root);
15572    }
15573
15574    #[test]
15575    fn fenced_refresh_with_current_ticket_commits_normally() {
15576        let _guard = REFRESH_WORKER_TEST_LOCK
15577            .lock()
15578            .unwrap_or_else(std::sync::PoisonError::into_inner);
15579        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15580        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15581        let pending = pending_paths();
15582        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15583
15584        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15585
15586        let lifecycle = SubcLifecycleAdmission::default();
15587        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
15588        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15589        let ticket = CallgraphRefreshTicket::new(
15590            lifecycle,
15591            generation,
15592            5,
15593            publish_epoch.clone(),
15594            publish_epoch.current(),
15595        );
15596
15597        enqueue_callgraph_store_refresh_fenced(
15598            callgraph_dir.clone(),
15599            root.clone(),
15600            vec![source.clone()],
15601            Arc::clone(&pending),
15602            ticket,
15603        );
15604        assert!(flush_callgraph_store_refreshes_with_budget(
15605            Duration::from_secs(5)
15606        ));
15607        assert_eq!(
15608            callgraph_refresh_worker_test_counts(&root).0,
15609            1,
15610            "current ticket must run the refresh"
15611        );
15612        assert!(
15613            pending.lock().is_empty(),
15614            "committed batch must not defer paths"
15615        );
15616
15617        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15618            .unwrap()
15619            .expect("published generation must remain readable");
15620        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15621        assert_eq!(
15622            tree.children[0].name, "new_leaf",
15623            "fenced commit must actually persist the refreshed content"
15624        );
15625        clear_callgraph_refresh_worker_test_seam(&root);
15626    }
15627
15628    #[test]
15629    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
15630        let _guard = REFRESH_WORKER_TEST_LOCK
15631            .lock()
15632            .unwrap_or_else(std::sync::PoisonError::into_inner);
15633        // Generous pre-drain: the refresh worker is process-wide, so a prior
15634        // test's still-running batch (slow Windows CI) must fully settle
15635        // before this test enqueues, or its wait deadline absorbs the
15636        // leftover work. Idle workers return immediately.
15637        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15638        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15639        let pending = pending_paths();
15640        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
15641
15642        enqueue_callgraph_store_refresh(
15643            callgraph_dir.clone(),
15644            root.clone(),
15645            vec![source.clone()],
15646            Arc::clone(&pending),
15647        );
15648        wait_for_refresh_calls(&root, 1);
15649        for _ in 0..3 {
15650            enqueue_callgraph_store_refresh(
15651                callgraph_dir.clone(),
15652                root.clone(),
15653                vec![source.clone()],
15654                Arc::clone(&pending),
15655            );
15656        }
15657
15658        assert!(flush_callgraph_store_refreshes_with_budget(
15659            Duration::from_secs(2)
15660        ));
15661        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
15662        assert!(pending.lock().is_empty());
15663        clear_callgraph_refresh_worker_test_seam(&root);
15664    }
15665
15666    #[test]
15667    fn queued_refresh_opens_generation_published_after_enqueue() {
15668        let _guard = REFRESH_WORKER_TEST_LOCK
15669            .lock()
15670            .unwrap_or_else(std::sync::PoisonError::into_inner);
15671        // Generous pre-drain: the refresh worker is process-wide, so a prior
15672        // test's still-running batch (slow Windows CI) must fully settle
15673        // before this test enqueues, or its wait deadline absorbs the
15674        // leftover work. Idle workers return immediately.
15675        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15676        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15677        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
15678        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
15679        let (active_held_rx, active_release_tx) =
15680            install_callgraph_refresh_worker_test_gate(active_root.clone());
15681        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
15682        enqueue_callgraph_store_refresh(
15683            active_dir,
15684            active_root.clone(),
15685            vec![active_source],
15686            pending_paths(),
15687        );
15688        active_held_rx
15689            .recv_timeout(Duration::from_secs(12))
15690            .expect("active refresh worker holds the queue");
15691
15692        fs::write(
15693            &target_source,
15694            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15695        )
15696        .unwrap();
15697        enqueue_callgraph_store_refresh(
15698            target_dir.clone(),
15699            target_root.clone(),
15700            vec![target_source.clone()],
15701            pending_paths(),
15702        );
15703        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
15704            target_dir.clone(),
15705            target_root.clone(),
15706            std::slice::from_ref(&target_source),
15707        )
15708        .unwrap();
15709        fs::write(
15710            &target_source,
15711            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15712        )
15713        .unwrap();
15714        drop(new_generation);
15715
15716        active_release_tx
15717            .send(())
15718            .expect("release active refresh worker");
15719        wait_for_refresh_calls(&target_root, 1);
15720        assert!(flush_callgraph_store_refreshes_with_budget(
15721            Duration::from_secs(12)
15722        ));
15723        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
15724            .unwrap()
15725            .expect("current callgraph generation");
15726        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15727        assert_eq!(tree.children[0].name, "worker_leaf");
15728        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
15729        clear_callgraph_refresh_worker_test_seam(&active_root);
15730        clear_callgraph_refresh_worker_test_seam(&target_root);
15731    }
15732
15733    #[test]
15734    fn refresh_failure_marks_files_stale() {
15735        let _guard = REFRESH_WORKER_TEST_LOCK
15736            .lock()
15737            .unwrap_or_else(std::sync::PoisonError::into_inner);
15738        // Generous pre-drain: the refresh worker is process-wide, so a prior
15739        // test's still-running batch (slow Windows CI) must fully settle
15740        // before this test enqueues, or its wait deadline absorbs the
15741        // leftover work. Idle workers return immediately.
15742        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15743        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15744        let pending = pending_paths();
15745        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
15746
15747        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
15748        assert!(flush_callgraph_store_refreshes_with_budget(
15749            Duration::from_secs(2)
15750        ));
15751
15752        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
15753        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
15754            .unwrap()
15755            .expect("ready callgraph store");
15756        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
15757        clear_callgraph_refresh_worker_test_seam(&root);
15758    }
15759
15760    #[test]
15761    fn idle_refresh_truncates_wal() {
15762        let _guard = REFRESH_WORKER_TEST_LOCK
15763            .lock()
15764            .unwrap_or_else(std::sync::PoisonError::into_inner);
15765        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15766        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15767        let generation = read_pointer(
15768            &callgraph_dir,
15769            &crate::search_index::artifact_cache_key(&root),
15770        )
15771        .expect("fixture publishes a generation");
15772        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15773        let pending = pending_paths();
15774        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15775
15776        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15777        enqueue_callgraph_store_refresh(
15778            callgraph_dir.clone(),
15779            root.clone(),
15780            vec![source.clone()],
15781            Arc::clone(&pending),
15782        );
15783        wait_for_refresh_calls(&root, 1);
15784        wait_for_refresh_worker_idle();
15785        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15786        while fs::metadata(&wal_path)
15787            .map(|metadata| metadata.len())
15788            .unwrap_or(0)
15789            != 0
15790        {
15791            assert!(
15792                Instant::now() < checkpoint_deadline,
15793                "idle checkpoint did not truncate WAL"
15794            );
15795            std::thread::sleep(Duration::from_millis(5));
15796        }
15797        assert_eq!(
15798            fs::metadata(&wal_path)
15799                .map(|metadata| metadata.len())
15800                .unwrap_or(0),
15801            0,
15802            "idle transition truncates the refresh WAL"
15803        );
15804
15805        clear_callgraph_refresh_worker_test_seam(&root);
15806    }
15807
15808    #[test]
15809    fn bounded_shutdown_defers_unprocessed_batches() {
15810        let _guard = REFRESH_WORKER_TEST_LOCK
15811            .lock()
15812            .unwrap_or_else(std::sync::PoisonError::into_inner);
15813        // Generous pre-drain: the refresh worker is process-wide, so a prior
15814        // test's still-running batch (slow Windows CI) must fully settle
15815        // before this test enqueues, or its wait deadline absorbs the
15816        // leftover work. Idle workers return immediately.
15817        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15818        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15819        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15820        let active_pending = pending_paths();
15821        let queued_pending = pending_paths();
15822        set_callgraph_refresh_worker_test_seam(
15823            active_root.clone(),
15824            Duration::from_millis(300),
15825            false,
15826        );
15827
15828        enqueue_callgraph_store_refresh(
15829            active_dir,
15830            active_root.clone(),
15831            vec![active_source.clone()],
15832            Arc::clone(&active_pending),
15833        );
15834        wait_for_refresh_calls(&active_root, 1);
15835        enqueue_callgraph_store_refresh(
15836            queued_dir,
15837            queued_root.clone(),
15838            vec![queued_source.clone()],
15839            Arc::clone(&queued_pending),
15840        );
15841
15842        assert!(!flush_callgraph_store_refreshes_with_budget(
15843            Duration::from_millis(20)
15844        ));
15845        assert!(active_pending.lock().contains(&active_source));
15846        assert!(queued_pending.lock().contains(&queued_source));
15847        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15848        clear_callgraph_refresh_worker_test_seam(&active_root);
15849    }
15850}
15851
15852#[cfg(test)]
15853mod cold_build_insert_tests {
15854    use super::*;
15855    use crate::imports::ImportBlock;
15856    use std::cell::Cell;
15857    use std::fs;
15858    use std::path::{Path, PathBuf};
15859    use tempfile::tempdir;
15860
15861    thread_local! {
15862        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15863        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15864        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15865    }
15866
15867    fn count_caller_traversal_selects(sql: &str) {
15868        let sql = sql.trim_start();
15869        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15870            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15871        }
15872        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15873            && sql.contains("e.target_file =")
15874        {
15875            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15876        }
15877        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15878            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15879        }
15880    }
15881
15882    #[test]
15883    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15884        let dir = tempdir().unwrap();
15885        let previous_root = dir.path().join("previous-root");
15886        let current_root = dir.path().join("current-root");
15887        fs::create_dir_all(&previous_root).unwrap();
15888        fs::create_dir_all(&current_root).unwrap();
15889        fs::remove_dir(&previous_root).unwrap();
15890        let mut conn = Connection::open_in_memory().unwrap();
15891        initialize_schema(&conn).unwrap();
15892        conn.execute(
15893            "INSERT INTO backend_file_state(
15894                backend, workspace_root, file_path, content_hash, status, updated_at
15895             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15896            params![previous_root.display().to_string()],
15897        )
15898        .unwrap();
15899
15900        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
15901
15902        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15903        assert_eq!(
15904            stored_workspace_roots(&conn).unwrap(),
15905            vec![previous_root.display().to_string()]
15906        );
15907    }
15908
15909    #[test]
15910    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15911        assert_eq!(
15912            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15913            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15914        );
15915    }
15916
15917    #[test]
15918    fn legacy_migration_completion_log_has_operator_fields() {
15919        assert_eq!(
15920            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15921            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15922        );
15923    }
15924
15925    fn write_generation_with_age(
15926        dir: &Path,
15927        project_key: &str,
15928        ordinal: u64,
15929        age: Duration,
15930    ) -> String {
15931        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15932        let path = dir.join(&generation);
15933        fs::write(&path, b"sqlite placeholder").unwrap();
15934        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15935        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15936        generation
15937    }
15938
15939    #[test]
15940    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15941        let dir = tempfile::tempdir().unwrap();
15942        let project_key = "project";
15943        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15944        let previous =
15945            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15946        let pinned =
15947            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15948        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15949
15950        gc_old_generations(dir.path(), project_key, &current);
15951
15952        assert!(dir.path().join(&previous).is_file());
15953        assert!(dir.path().join(&pinned).is_file());
15954
15955        drop(marker);
15956        gc_old_generations(dir.path(), project_key, &current);
15957
15958        assert!(dir.path().join(&previous).is_file());
15959        assert!(!dir.path().join(&pinned).exists());
15960    }
15961
15962    #[test]
15963    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15964        let dir = tempfile::tempdir().unwrap();
15965        let project_key = "project";
15966        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15967        let _previous =
15968            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15969        let pinned =
15970            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15971        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15972        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15973
15974        gc_old_generations(dir.path(), project_key, &current);
15975
15976        assert!(dir.path().join(&pinned).is_file());
15977    }
15978
15979    #[test]
15980    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15981        let dir = tempfile::tempdir().unwrap();
15982        let project_key = "project";
15983        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15984        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15985        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15986        let old = write_generation_with_age(
15987            dir.path(),
15988            project_key,
15989            200,
15990            expired + Duration::from_secs(60),
15991        );
15992        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15993
15994        gc_old_generations(dir.path(), project_key, &current);
15995
15996        assert!(dir.path().join(&current).is_file());
15997        assert!(dir.path().join(&previous).is_file());
15998        assert!(!dir.path().join(&old).exists());
15999    }
16000
16001    fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
16002        let cache_dir = callgraph_root.join(key);
16003        fs::create_dir_all(cache_dir.join("nested")).unwrap();
16004        fs::write(
16005            cache_dir.join("nested").join("payload.sqlite"),
16006            b"old cache payload",
16007        )
16008        .unwrap();
16009        age_callgraph_root_tree(&cache_dir);
16010        cache_dir
16011    }
16012
16013    fn age_callgraph_root_tree(path: &Path) {
16014        let old = SystemTime::now()
16015            .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
16016            .unwrap_or(UNIX_EPOCH);
16017        let entries = fs::read_dir(path)
16018            .unwrap()
16019            .collect::<std::io::Result<Vec<_>>>()
16020            .unwrap();
16021        for entry in entries {
16022            let child = entry.path();
16023            if entry.file_type().unwrap().is_dir() {
16024                age_callgraph_root_tree(&child);
16025            } else {
16026                filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
16027                    .unwrap();
16028            }
16029        }
16030        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
16031    }
16032
16033    #[test]
16034    fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
16035        reset_callgraph_root_sweep_cursor_for_test();
16036        let storage = tempdir().unwrap();
16037        let callgraph_root = storage.path().join("callgraph");
16038        let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
16039        let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
16040        let fresh = callgraph_root.join("d1c2b3a495867768");
16041        fs::create_dir_all(&fresh).unwrap();
16042        fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
16043        let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
16044
16045        let writer_lease = crate::fs_lock::try_acquire(
16046            &crate::root_cache::writer_lease_path(&leased),
16047            Duration::ZERO,
16048        )
16049        .unwrap();
16050        age_callgraph_root_tree(&leased);
16051        let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
16052        // Same-host marker protection is PID-authoritative, so this old mtime
16053        // proves the reader guard instead of accidentally relying on freshness.
16054        age_callgraph_root_tree(&marked);
16055
16056        let first = sweep_callgraph_root_dirs_with_limits(
16057            &callgraph_root,
16058            &HashSet::new(),
16059            &HashSet::new(),
16060            CALLGRAPH_ROOT_SWEEP_BUDGET,
16061            usize::MAX,
16062        );
16063
16064        assert_eq!(first.removed, 1);
16065        assert!(first.bytes > 0, "the reaped byte count must be reported");
16066        assert!(!dead.exists(), "an aged dead root must be reaped");
16067        assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
16068        assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
16069        assert_eq!(first.skipped_fresh, 1, "a recent root must win");
16070        assert!(leased.is_dir(), "the leased root must survive");
16071        assert!(marked.is_dir(), "the reader-marked root must survive");
16072        assert!(fresh.is_dir(), "the recent root must survive");
16073
16074        drop(writer_lease);
16075        drop(marker);
16076        // Mutation controls: removing each guard and aging each payload makes
16077        // every initially protected decoy eligible for the next pass.
16078        for cache_dir in [&leased, &marked, &fresh] {
16079            age_callgraph_root_tree(cache_dir);
16080        }
16081        let second = sweep_callgraph_root_dirs_with_limits(
16082            &callgraph_root,
16083            &HashSet::new(),
16084            &HashSet::new(),
16085            CALLGRAPH_ROOT_SWEEP_BUDGET,
16086            usize::MAX,
16087        );
16088
16089        assert_eq!(second.removed, 3);
16090        for cache_dir in [&leased, &marked, &fresh] {
16091            assert!(
16092                !cache_dir.exists(),
16093                "the decoy must be reaped after its guard or freshness changes"
16094            );
16095        }
16096        reset_callgraph_root_sweep_cursor_for_test();
16097    }
16098
16099    #[test]
16100    fn callgraph_root_sweep_resumes_after_entry_budget() {
16101        reset_callgraph_root_sweep_cursor_for_test();
16102        let storage = tempdir().unwrap();
16103        let callgraph_root = storage.path().join("callgraph");
16104        let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
16105        let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
16106        let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
16107
16108        let first_pass = sweep_callgraph_root_dirs_with_limits(
16109            &callgraph_root,
16110            &HashSet::new(),
16111            &HashSet::new(),
16112            CALLGRAPH_ROOT_SWEEP_BUDGET,
16113            1,
16114        );
16115        assert!(first_pass.budget_exhausted);
16116        assert_eq!(first_pass.scanned, 1);
16117        assert!(!first.exists());
16118        assert!(second.exists());
16119        assert!(third.exists());
16120
16121        let second_pass = sweep_callgraph_root_dirs_with_limits(
16122            &callgraph_root,
16123            &HashSet::new(),
16124            &HashSet::new(),
16125            CALLGRAPH_ROOT_SWEEP_BUDGET,
16126            1,
16127        );
16128        assert!(second_pass.budget_exhausted);
16129        assert!(!second.exists());
16130        assert!(third.exists());
16131
16132        let third_pass = sweep_callgraph_root_dirs_with_limits(
16133            &callgraph_root,
16134            &HashSet::new(),
16135            &HashSet::new(),
16136            CALLGRAPH_ROOT_SWEEP_BUDGET,
16137            1,
16138        );
16139        assert!(!third_pass.budget_exhausted);
16140        assert!(!third.exists());
16141        reset_callgraph_root_sweep_cursor_for_test();
16142    }
16143
16144    #[test]
16145    fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
16146        reset_callgraph_root_sweep_cursor_for_test();
16147        let storage = tempdir().unwrap();
16148        let callgraph_root = storage.path().join("callgraph");
16149        let key = "a1b2c3d4e5f60718";
16150        let cache_dir = callgraph_root.join(key);
16151        fs::create_dir_all(&cache_dir).unwrap();
16152        let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
16153        let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
16154        let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
16155        publish_pointer(&cache_dir, key, &current).unwrap();
16156        age_callgraph_root_tree(&cache_dir);
16157        let memo_keys = HashSet::from([key.to_string()]);
16158
16159        let summary = sweep_callgraph_root_dirs_with_limits(
16160            &callgraph_root,
16161            &memo_keys,
16162            &HashSet::new(),
16163            CALLGRAPH_ROOT_SWEEP_BUDGET,
16164            usize::MAX,
16165        );
16166
16167        assert_eq!(summary.generation_gc, 1);
16168        assert!(cache_dir.join(&current).is_file());
16169        assert!(cache_dir.join(&previous).is_file());
16170        assert!(
16171            !cache_dir.join(&obsolete).exists(),
16172            "the store-wide sweep must collect an inactive live root's obsolete generation"
16173        );
16174        reset_callgraph_root_sweep_cursor_for_test();
16175    }
16176
16177    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
16178        let path = dir.join(name);
16179        fs::write(&path, b"temp placeholder").unwrap();
16180        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
16181        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
16182        path
16183    }
16184
16185    #[test]
16186    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
16187        let dir = tempdir().unwrap();
16188        // One directory holds both an aged orphan (with its journal sidecar) and a
16189        // fresh temporary, so this proves the sweep SELECTS by age rather than
16190        // deleting everything in the directory.
16191        let aged = "project.g100.1.sqlite.tmp.1.200";
16192        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
16193        let fresh = "project.g300.1.sqlite.tmp.1.400";
16194        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
16195        write_build_temp_with_age(dir.path(), aged, aged_age);
16196        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
16197        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16198
16199        sweep_orphaned_build_temps(dir.path());
16200
16201        assert!(
16202            !dir.path().join(aged).exists(),
16203            "aged orphan must be removed"
16204        );
16205        assert!(
16206            !dir.path().join(aged_journal).exists(),
16207            "aged journal sidecar must be removed"
16208        );
16209        assert!(
16210            dir.path().join(fresh).is_file(),
16211            "fresh temporary must survive"
16212        );
16213    }
16214
16215    #[test]
16216    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
16217        let storage = tempdir().unwrap();
16218        let storage_root = storage.path();
16219        // The production shape: a legacy per-harness store whose root no longer
16220        // builds there — no `.current` pointer, no running build — so the per-root
16221        // cleanup never fires for it. A sibling root still building in the
16222        // root-keyed store triggers the store-wide sweep, which must reach into the
16223        // legacy directory and reclaim the orphan.
16224        let legacy_dir = storage_root.join("opencode").join("callgraph");
16225        fs::create_dir_all(&legacy_dir).unwrap();
16226        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
16227        write_build_temp_with_age(
16228            &legacy_dir,
16229            orphan,
16230            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16231        );
16232        assert!(
16233            !legacy_dir.join("deadbeef.current").exists(),
16234            "the dead root has no current pointer"
16235        );
16236
16237        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
16238        fs::create_dir_all(&root_keyed_dir).unwrap();
16239
16240        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
16241
16242        assert!(
16243            !legacy_dir.join(orphan).exists(),
16244            "legacy orphan must be reclaimed by the store-wide sweep"
16245        );
16246    }
16247
16248    #[test]
16249    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
16250        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
16251        // everything (min_age = 0) removes the fresh temporary that the real 24h
16252        // threshold spares in the test above. If a mutation to the age check leaves
16253        // the fresh file in place here, the predicate is no longer doing the
16254        // selection work the fresh-survives assertion relies on.
16255        let dir = tempdir().unwrap();
16256        let fresh = "project.g300.1.sqlite.tmp.1.400";
16257        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16258
16259        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
16260
16261        assert!(
16262            !dir.path().join(fresh).exists(),
16263            "with the age predicate forced open, the fresh temporary is removed"
16264        );
16265    }
16266
16267    #[test]
16268    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
16269        let dir = tempdir().unwrap();
16270        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
16271        // to be swept, plus a live read marker, is generation GC's jurisdiction.
16272        // The orphan sweep must not intersect it.
16273        let generation = write_generation_with_age(
16274            dir.path(),
16275            "project",
16276            400,
16277            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16278        );
16279        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16280
16281        sweep_orphaned_build_temps(dir.path());
16282
16283        assert!(
16284            dir.path().join(&generation).is_file(),
16285            "completed generation must survive the orphan sweep"
16286        );
16287        assert!(
16288            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
16289            "read marker must survive the orphan sweep"
16290        );
16291    }
16292
16293    #[test]
16294    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
16295        let dir = tempfile::tempdir().unwrap();
16296        let project_key = "project".to_string();
16297        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
16298        let sqlite_path = dir.path().join(&generation);
16299        fs::remove_file(&sqlite_path).unwrap();
16300        let conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration).unwrap();
16301        let store = CallGraphStore::from_connection(
16302            dir.path().to_path_buf(),
16303            project_key,
16304            sqlite_path,
16305            dir.path().to_path_buf(),
16306            false,
16307            Some(generation.clone()),
16308            None,
16309            None,
16310            conn,
16311        );
16312
16313        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16314        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
16315
16316        drop(marker);
16317        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
16318    }
16319
16320    #[test]
16321    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
16322        let dir = tempdir().expect("temp dir");
16323        let file = dir.path().join("main.ts");
16324        fs::write(&file, "export function main() {}\n").expect("write fixture");
16325        let store = CallGraphStore::open(
16326            dir.path().join(".store-readiness-cache"),
16327            dir.path().to_path_buf(),
16328        )
16329        .expect("open store");
16330        {
16331            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16332            conn.trace(Some(count_caller_traversal_selects));
16333        }
16334
16335        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16336        assert!(store.indexed_file_count().is_err());
16337        assert!(store.indexed_file_count().is_err());
16338        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16339
16340        store
16341            .cold_build(std::slice::from_ref(&file))
16342            .expect("cold build");
16343        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16344        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
16345        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
16346        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
16347
16348        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16349        conn.trace(None);
16350    }
16351
16352    #[test]
16353    fn direct_caller_frontier_chunks_sqlite_selects() {
16354        let dir = tempdir().expect("temp dir");
16355        let file = dir.path().join("main.ts");
16356        fs::write(
16357            &file,
16358            "export function caller() { target(); }\nexport function target() {}\n",
16359        )
16360        .expect("write fixture");
16361        let store = CallGraphStore::open(
16362            dir.path().join(".store-caller-frontier-query"),
16363            dir.path().to_path_buf(),
16364        )
16365        .expect("open store");
16366        store
16367            .cold_build(std::slice::from_ref(&file))
16368            .expect("cold build");
16369        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
16370        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
16371
16372        CALLER_QUERY_SELECTS.with(|count| count.set(0));
16373        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16374        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16375        {
16376            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16377            conn.trace(Some(count_caller_traversal_selects));
16378        }
16379        let callers = store
16380            .direct_callers_for_symbols(&targets)
16381            .expect("batched callers");
16382        {
16383            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16384            conn.trace(None);
16385        }
16386
16387        assert_eq!(callers.len(), 1_000);
16388        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
16389        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
16390        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
16391        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16392    }
16393
16394    #[test]
16395    fn callers_depth_boundary_batches_sqlite_counts() {
16396        const CALLER_COUNT: usize = 1_000;
16397
16398        let dir = tempdir().expect("temp dir");
16399        let file = dir.path().join("main.ts");
16400        let mut source = String::from("export function sharedHotHelper() {}\n");
16401        for index in 0..CALLER_COUNT {
16402            source.push_str(&format!(
16403                "export function caller{index}() {{ sharedHotHelper(); }}\n"
16404            ));
16405        }
16406        fs::write(&file, source).expect("write fixture");
16407
16408        let store = CallGraphStore::open(
16409            dir.path().join(".store-callers-query-fanout"),
16410            dir.path().to_path_buf(),
16411        )
16412        .expect("open store");
16413        store
16414            .cold_build(std::slice::from_ref(&file))
16415            .expect("cold build");
16416
16417        CALLER_QUERY_SELECTS.with(|count| count.set(0));
16418        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16419        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16420        {
16421            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16422            conn.trace(Some(count_caller_traversal_selects));
16423        }
16424
16425        let started = Instant::now();
16426        let result = crate::commands::callgraph_store_adapter::callers_result(
16427            &store,
16428            Path::new("main.ts"),
16429            "sharedHotHelper",
16430            1,
16431            true,
16432        )
16433        .expect("callers result");
16434        let elapsed = started.elapsed();
16435
16436        {
16437            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16438            conn.trace(None);
16439        }
16440        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
16441        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
16442        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
16443        eprintln!(
16444            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
16445            result.total_callers,
16446            caller_queries,
16447            boundary_queries,
16448            total_selects,
16449            elapsed.as_secs_f64() * 1_000.0
16450        );
16451
16452        assert_eq!(result.total_callers, CALLER_COUNT);
16453        assert_eq!(caller_queries, 1);
16454        assert_eq!(boundary_queries, 3);
16455        assert_eq!(total_selects, 9);
16456    }
16457
16458    #[test]
16459    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
16460        let dir = tempdir().expect("temp dir");
16461        let file = dir.path().join("main.ts");
16462        fs::write(
16463            &file,
16464            r#"export function topA() {
16465  root();
16466}
16467
16468export function topB() {
16469  root();
16470}
16471
16472export function root() {
16473  leaf();
16474  missing();
16475}
16476
16477export function leaf() {}
16478"#,
16479        )
16480        .expect("write fixture");
16481
16482        let store = CallGraphStore::open(
16483            dir.path().join(".store-depth-boundary-counts"),
16484            dir.path().to_path_buf(),
16485        )
16486        .expect("open store");
16487        store
16488            .cold_build(std::slice::from_ref(&file))
16489            .expect("cold build");
16490
16491        let root = store
16492            .node_for(Path::new("main.ts"), "root")
16493            .expect("root node");
16494        let leaf = store
16495            .node_for(Path::new("main.ts"), "leaf")
16496            .expect("leaf node");
16497
16498        let (full_forward_len, full_direct_len) = {
16499            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16500            conn.execute(
16501                "INSERT INTO edges (
16502                    edge_id, ref_id, source_node, target_node, target_file,
16503                    target_symbol, kind, line, provenance
16504                 ) VALUES (
16505                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
16506                    ?2, ?3, 'call', 98, ?4
16507                 )",
16508                rusqlite::params![
16509                    &root.node_id,
16510                    &leaf.file,
16511                    &leaf.symbol,
16512                    PROVENANCE_TREESITTER
16513                ],
16514            )
16515            .expect("insert dangling forward edge");
16516            conn.execute(
16517                "INSERT INTO edges (
16518                    edge_id, ref_id, source_node, target_node, target_file,
16519                    target_symbol, kind, line, provenance
16520                 ) VALUES (
16521                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
16522                    ?1, ?2, ?3, 'call', 99, ?4
16523                 )",
16524                rusqlite::params![
16525                    &root.node_id,
16526                    &root.file,
16527                    &root.symbol,
16528                    PROVENANCE_TREESITTER
16529                ],
16530            )
16531            .expect("insert dangling direct-caller edge");
16532
16533            let full_forward_len = forward_calls_for_node(&conn, &root)
16534                .expect("full forward calls")
16535                .len();
16536            let counted_forward_len =
16537                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
16538            assert_eq!(
16539                counted_forward_len, full_forward_len,
16540                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
16541            );
16542
16543            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
16544                .expect("full direct callers");
16545            let full_direct_len = full_direct.len();
16546            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
16547                .expect("counted direct callers");
16548            assert_eq!(
16549                counted_direct_len, full_direct_len,
16550                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
16551            );
16552
16553            let distinct_direct_len = full_direct
16554                .iter()
16555                .map(|site| {
16556                    (
16557                        site.caller.file.clone(),
16558                        site.line,
16559                        site.target_file.clone(),
16560                        site.target_symbol.clone(),
16561                    )
16562                })
16563                .collect::<BTreeSet<_>>()
16564                .len();
16565            let batch_counts = direct_caller_counts_for_tuples(
16566                &conn,
16567                &[
16568                    (root.file.clone(), root.symbol.clone()),
16569                    (root.file.clone(), root.symbol.clone()),
16570                    (leaf.file.clone(), leaf.symbol.clone()),
16571                ],
16572            )
16573            .expect("batched direct-caller counts");
16574            assert_eq!(batch_counts.len(), 2);
16575            assert_eq!(
16576                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
16577                Some(&distinct_direct_len)
16578            );
16579
16580            (full_forward_len, full_direct_len)
16581        };
16582
16583        assert_eq!(
16584            full_forward_len, 2,
16585            "fixture root should have one resolved and one unresolved outgoing call"
16586        );
16587        assert_eq!(
16588            full_direct_len, 2,
16589            "fixture root should have two real direct callers"
16590        );
16591
16592        let tree = store
16593            .call_tree(Path::new("main.ts"), "root", 0)
16594            .expect("call tree");
16595        assert!(tree.depth_limited);
16596        assert_eq!(tree.children.len(), 0);
16597        assert_eq!(
16598            tree.truncated, full_forward_len,
16599            "call_tree depth boundary must report the full forward-call list length"
16600        );
16601
16602        let callers = store
16603            .callers_of(Path::new("main.ts"), "leaf", 0)
16604            .expect("callers");
16605        assert!(callers.depth_limited);
16606        assert_eq!(callers.callers.len(), 1);
16607        assert_eq!(callers.callers[0].caller.symbol, "root");
16608        assert_eq!(
16609            callers.truncated, full_direct_len,
16610            "callers depth boundary must report the full direct-caller list length"
16611        );
16612    }
16613
16614    #[test]
16615    fn source_freshness_matches_cache_collect_for_same_bytes() {
16616        let dir = tempdir().expect("temp dir");
16617        let path = dir.path().join("fixture.ts");
16618        let source = "export function main() { return helper(); }\n";
16619        fs::write(&path, source).expect("write fixture");
16620
16621        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
16622        let actual =
16623            collect_source_freshness(&path, source).expect("collect freshness from source");
16624
16625        assert_eq!(actual, expected);
16626    }
16627
16628    #[test]
16629    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
16630        let root = tempfile::tempdir().unwrap();
16631        let callgraph_dir = tempfile::tempdir().unwrap();
16632        let source_dir = root.path().join("src");
16633        std::fs::create_dir_all(&source_dir).unwrap();
16634        let source = source_dir.join("lib.rs");
16635        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
16636        let files = vec![source.clone()];
16637        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16638        let old_epoch = epoch.next();
16639        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
16640        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
16641        let old_epoch_flag = epoch.clone();
16642        let old_dir = callgraph_dir.path().to_path_buf();
16643        let old_root = root.path().to_path_buf();
16644        let old_files = files.clone();
16645        let old = std::thread::spawn(move || {
16646            set_cold_build_before_publish_observer(Some(Arc::new(move || {
16647                reached_tx.send(()).unwrap();
16648                release_rx.recv().unwrap();
16649            })));
16650            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
16651                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
16652            });
16653            set_cold_build_before_publish_observer(None);
16654            result
16655        });
16656        // Positive wait: the older build runs a real cold build (git probe +
16657        // SQLite schema init) before the barrier, which can exceed 5s on a
16658        // contended Windows CI runner. Only negative waits stay short.
16659        reached_rx
16660            .recv_timeout(Duration::from_secs(30))
16661            .expect("older build did not reach its publication barrier");
16662
16663        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
16664        let new_epoch = epoch.next();
16665        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
16666            CallGraphStore::cold_build_with_lease(
16667                callgraph_dir.path().to_path_buf(),
16668                root.path().to_path_buf(),
16669                &files,
16670            )
16671        })
16672        .expect("newer build should publish");
16673        drop(new_store);
16674
16675        release_tx.send(()).unwrap();
16676        assert!(matches!(
16677            old.join().unwrap(),
16678            Err(CallGraphStoreError::Superseded)
16679        ));
16680
16681        let current = CallGraphStore::open_readonly(
16682            callgraph_dir.path().to_path_buf(),
16683            root.path().to_path_buf(),
16684        )
16685        .unwrap()
16686        .expect("current callgraph generation");
16687        assert_eq!(
16688            current
16689                .nodes_matching("new_generation_marker")
16690                .unwrap()
16691                .len(),
16692            1
16693        );
16694        assert!(current
16695            .nodes_matching("old_generation_marker")
16696            .unwrap()
16697            .is_empty());
16698    }
16699
16700    #[test]
16701    fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
16702        let root = tempfile::tempdir().unwrap();
16703        let callgraph_dir = tempfile::tempdir().unwrap();
16704        let source = root.path().join("lib.rs");
16705        std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
16706        let files = vec![source];
16707        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16708        let old_epoch = epoch.next();
16709        let epoch_for_observer = epoch.clone();
16710        set_cold_build_before_publish_observer(Some(Arc::new(move || {
16711            epoch_for_observer.next();
16712        })));
16713        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16714            CallGraphStore::cold_build_with_lease_chunked(
16715                callgraph_dir.path().to_path_buf(),
16716                root.path().to_path_buf(),
16717                &files,
16718                1,
16719            )
16720        });
16721        set_cold_build_before_publish_observer(None);
16722        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16723
16724        let project_key = crate::search_index::artifact_cache_key(root.path());
16725        let staging = callgraph_dir
16726            .path()
16727            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16728        let staged = Connection::open(&staging).unwrap();
16729        assert_eq!(
16730            staged_build_phase(&staged).unwrap().as_deref(),
16731            Some("ready")
16732        );
16733        drop(staged);
16734
16735        let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16736        let extracted_for_observer = Arc::clone(&extracted);
16737        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16738            extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
16739        })));
16740        let successor_epoch = epoch.next();
16741        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16742            CallGraphStore::cold_build_with_lease_chunked(
16743                callgraph_dir.path().to_path_buf(),
16744                root.path().to_path_buf(),
16745                &files,
16746                1,
16747            )
16748        })
16749        .expect("completed same-corpus staging publishes without rebuilding");
16750        set_cold_build_extract_observer(None);
16751
16752        assert_eq!(stats.files, 1);
16753        assert_eq!(
16754            extracted.load(AtomicOrdering::SeqCst),
16755            0,
16756            "completed staging must not repeat extraction"
16757        );
16758        drop(store);
16759    }
16760
16761    #[test]
16762    fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16763        let root = tempfile::tempdir().unwrap();
16764        let callgraph_dir = tempfile::tempdir().unwrap();
16765        let files = ["a.rs", "b.rs", "c.rs"]
16766            .into_iter()
16767            .map(|name| {
16768                let path = root.path().join(name);
16769                std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16770                    .unwrap();
16771                path
16772            })
16773            .collect::<Vec<_>>();
16774        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16775        let old_epoch = epoch.next();
16776        let superseded = Arc::new(AtomicBool::new(false));
16777        let epoch_for_observer = epoch.clone();
16778        let superseded_for_observer = Arc::clone(&superseded);
16779        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16780            if stage == "extraction"
16781                && completed == 1
16782                && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16783            {
16784                epoch_for_observer.next();
16785            }
16786        })));
16787
16788        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16789            CallGraphStore::cold_build_with_lease_chunked(
16790                callgraph_dir.path().to_path_buf(),
16791                root.path().to_path_buf(),
16792                &files,
16793                1,
16794            )
16795        });
16796        set_cold_build_slice_observer(None);
16797        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16798        assert!(superseded.load(AtomicOrdering::SeqCst));
16799
16800        let project_key = crate::search_index::artifact_cache_key(root.path());
16801        let staging = callgraph_dir
16802            .path()
16803            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16804        assert!(staging.exists(), "supersession must retain durable staging");
16805        let staged = Connection::open(&staging).unwrap();
16806        assert_eq!(
16807            staged_build_phase(&staged).unwrap().as_deref(),
16808            Some("extracting")
16809        );
16810        assert_eq!(
16811            query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16812            1
16813        );
16814        drop(staged);
16815
16816        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16817        let extracted_for_observer = Arc::clone(&extracted);
16818        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16819            extracted_for_observer
16820                .lock()
16821                .unwrap()
16822                .extend(paths.iter().filter_map(|path| {
16823                    path.file_name()
16824                        .map(|name| name.to_string_lossy().into_owned())
16825                }));
16826        })));
16827        let successor_epoch = epoch.next();
16828        let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16829            CallGraphStore::cold_build_with_lease_chunked(
16830                callgraph_dir.path().to_path_buf(),
16831                root.path().to_path_buf(),
16832                &files,
16833                1,
16834            )
16835        })
16836        .expect("same-corpus successor resumes and publishes");
16837        set_cold_build_extract_observer(None);
16838
16839        assert_eq!(stats.files, 3);
16840        assert_eq!(
16841            *extracted.lock().unwrap(),
16842            vec!["b.rs".to_string(), "c.rs".to_string()],
16843            "the successor must not repeat the committed first slice"
16844        );
16845        drop(store);
16846        assert!(
16847            !staging.exists(),
16848            "published staging moves to its generation"
16849        );
16850    }
16851
16852    #[test]
16853    fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16854        let root = tempfile::tempdir().unwrap();
16855        let callgraph_dir = tempfile::tempdir().unwrap();
16856        let first = root.path().join("a.rs");
16857        let second = root.path().join("b.rs");
16858        std::fs::write(&first, "pub fn a() {}\n").unwrap();
16859        std::fs::write(&second, "pub fn b() {}\n").unwrap();
16860        let mut files = vec![first.clone(), second.clone()];
16861        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16862        let old_epoch = epoch.next();
16863        let advanced = Arc::new(AtomicBool::new(false));
16864        let epoch_for_observer = epoch.clone();
16865        let advanced_for_observer = Arc::clone(&advanced);
16866        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16867            if stage == "extraction"
16868                && completed == 1
16869                && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16870            {
16871                epoch_for_observer.next();
16872            }
16873        })));
16874        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16875            CallGraphStore::cold_build_with_lease_chunked(
16876                callgraph_dir.path().to_path_buf(),
16877                root.path().to_path_buf(),
16878                &files,
16879                1,
16880            )
16881        });
16882        set_cold_build_slice_observer(None);
16883        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16884
16885        std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16886        let third = root.path().join("c.rs");
16887        std::fs::write(&third, "pub fn c() {}\n").unwrap();
16888        files.push(third);
16889        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16890        let extracted_for_observer = Arc::clone(&extracted);
16891        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16892            extracted_for_observer
16893                .lock()
16894                .unwrap()
16895                .extend(paths.iter().filter_map(|path| {
16896                    path.file_name()
16897                        .map(|name| name.to_string_lossy().into_owned())
16898                }));
16899        })));
16900        let successor_epoch = epoch.next();
16901        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16902            CallGraphStore::cold_build_with_lease_chunked(
16903                callgraph_dir.path().to_path_buf(),
16904                root.path().to_path_buf(),
16905                &files,
16906                1,
16907            )
16908        })
16909        .expect("changed-corpus successor restarts and publishes");
16910        set_cold_build_extract_observer(None);
16911
16912        assert_eq!(stats.files, 3);
16913        assert_eq!(
16914            *extracted.lock().unwrap(),
16915            vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16916            "fingerprint mismatch must invalidate every old extraction slice"
16917        );
16918        drop(store);
16919    }
16920
16921    #[test]
16922    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16923        let dir = tempdir().expect("temp dir");
16924        let project_root = dir.path();
16925        let extract = fixture_extract(project_root);
16926        let resolved = fixture_resolved(&extract);
16927
16928        let reference = build_reference_connection(project_root, &extract, &resolved);
16929        let optimized = build_optimized_connection(project_root, &extract, &resolved);
16930
16931        for table in [
16932            "files",
16933            "nodes",
16934            "file_dependencies",
16935            "dispatch_hints",
16936            "refs",
16937            "edges",
16938        ] {
16939            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
16940            // the reference and optimized builds run sequentially and can straddle a
16941            // one-second tick under load, so it is legitimately allowed to differ.
16942            // This mirrors the existing exclusions of `backend_file_state.updated_at`
16943            // and the chunked-vs-unchunked sibling test. The check is for structural
16944            // row equivalence of the optimized bulk insert, not wall-clock equality.
16945            let excluded: &[&str] = if table == "files" {
16946                &["indexed_at"]
16947            } else {
16948                &[]
16949            };
16950            assert_eq!(
16951                table_rows_without(&reference, table, excluded),
16952                table_rows_without(&optimized, table, excluded),
16953                "table `{table}` rows must match apart from wall-clock columns"
16954            );
16955        }
16956        assert_eq!(
16957            backend_state_rows(&reference),
16958            backend_state_rows(&optimized),
16959            "backend freshness rows must match apart from updated_at"
16960        );
16961        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16962    }
16963
16964    #[test]
16965    fn cold_build_chunked_matches_unchunked_logical_rows() {
16966        let dir = tempdir().expect("temp dir");
16967        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16968        write_chunked_equivalence_fixture(&project_root);
16969        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16970        assert!(
16971            files.len() > 6,
16972            "fixture should be large enough to split into multiple chunks"
16973        );
16974
16975        let unchunked = CallGraphStore::open(
16976            project_root.join(".store-unchunked"),
16977            project_root.to_path_buf(),
16978        )
16979        .expect("open unchunked store");
16980        let unchunked_stats = unchunked
16981            .cold_build_chunked(&files, 0)
16982            .expect("unchunked cold build");
16983
16984        let chunked = CallGraphStore::open(
16985            project_root.join(".store-chunked"),
16986            project_root.to_path_buf(),
16987        )
16988        .expect("open chunked store");
16989        let chunked_stats = chunked
16990            .cold_build_chunked(&files, 3)
16991            .expect("chunked cold build");
16992
16993        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16994        assert_eq!(
16995            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16996            chunked.edge_snapshot().expect("chunked edge snapshot"),
16997            "public edge snapshots must match"
16998        );
16999
17000        let dispatch_edges = {
17001            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
17002            conn.query_row(
17003                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
17004                [],
17005                |row| row.get::<_, i64>(0),
17006            )
17007            .expect("count dispatch edges")
17008        };
17009        assert!(
17010            dispatch_edges > 0,
17011            "fixture must exercise method-dispatch edge insertion"
17012        );
17013
17014        for table in [
17015            "edges",
17016            "refs",
17017            "nodes",
17018            "file_dependencies",
17019            "dispatch_hints",
17020        ] {
17021            assert_eq!(
17022                graph_table_rows(&unchunked, table),
17023                graph_table_rows(&chunked, table),
17024                "chunked cold build must match unchunked rows for {table}"
17025            );
17026        }
17027        assert_eq!(
17028            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
17029            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
17030            "files rows must match apart from indexed_at"
17031        );
17032        assert_eq!(
17033            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
17034            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
17035            "backend freshness rows must match apart from updated_at"
17036        );
17037
17038        let published_dir = project_root.join(".store-published");
17039        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
17040            published_dir.clone(),
17041            project_root.to_path_buf(),
17042            &files,
17043            0,
17044        )
17045        .expect("published unchunked cold build");
17046        assert!(
17047            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
17048                .expect("needs_cold_build after publish"),
17049            "published store should be ready"
17050        );
17051        drop(_published);
17052        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
17053            published_dir,
17054            project_root.to_path_buf(),
17055            &files,
17056            3,
17057        )
17058        .expect("ensure with a different chunk size");
17059        assert!(
17060            rebuild_stats.is_none(),
17061            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
17062        );
17063    }
17064
17065    #[test]
17066    fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
17067        let dir = tempdir().expect("temp dir");
17068        let project_root = dir.path().join("project");
17069        fs::create_dir_all(&project_root).expect("create project root");
17070        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17071        let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
17072        let resolve_window = 19;
17073
17074        callgraph::clear_workspace_package_cache();
17075        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
17076        let uncached = CallGraphStore::open(
17077            dir.path().join("store-uncached"),
17078            project_root.to_path_buf(),
17079        )
17080        .expect("open uncached store");
17081        // Bypass the outer disk-index memo so this comparison still isolates
17082        // the filesystem-facing module-resolution memo.
17083        let uncached_stats = uncached
17084            .cold_build_chunked_with_disk_index_memo_for_test(
17085                &files,
17086                7,
17087                resolve_window,
17088                &uncached_memo,
17089                false,
17090            )
17091            .expect("uncached comparison build");
17092        assert!(
17093            uncached_stats.refs > resolve_window * 2,
17094            "fixture must cross several staged reference windows"
17095        );
17096
17097        callgraph::clear_workspace_package_cache();
17098        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17099        let cached =
17100            CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
17101                .expect("open cached store");
17102        let cached_stats = cached
17103            .cold_build_chunked_with_resolution_memo_for_test(
17104                &files,
17105                7,
17106                resolve_window,
17107                &cached_memo,
17108            )
17109            .expect("cached build");
17110
17111        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17112        for table in [
17113            "nodes",
17114            "refs",
17115            "file_dependencies",
17116            "edges",
17117            "dispatch_hints",
17118            "type_ref_names",
17119            "meta",
17120            "staging_file_inventory",
17121            "staging_ref_context",
17122        ] {
17123            assert_eq!(
17124                graph_table_rows(&uncached, table),
17125                graph_table_rows(&cached, table),
17126                "memoized and uncached cold builds must produce identical {table} rows"
17127            );
17128        }
17129        assert_eq!(
17130            graph_table_rows_without(&uncached, "files", &["indexed_at"]),
17131            graph_table_rows_without(&cached, "files", &["indexed_at"]),
17132            "files rows must match apart from indexed_at"
17133        );
17134        assert_eq!(
17135            graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
17136            graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
17137            "backend rows must match apart from updated_at"
17138        );
17139
17140        let cached_module_computations = cached_memo.module_computations_for_test();
17141        assert!(
17142            !cached_module_computations.is_empty(),
17143            "fixture must exercise module resolution"
17144        );
17145        assert!(
17146            cached_module_computations.values().all(|count| *count == 1),
17147            "each importing-directory/specifier pair must reach the filesystem once"
17148        );
17149        let uncached_module_computations = uncached_memo.module_computations_for_test();
17150        assert!(
17151            uncached_module_computations
17152                .values()
17153                .copied()
17154                .max()
17155                .unwrap_or_default()
17156                > 16,
17157            "mutation control: disabling the memo must recompute a hot module target"
17158        );
17159
17160        let cached_package_probes = cached_memo
17161            .json_probes_for_test()
17162            .into_iter()
17163            .filter(|(path, _)| {
17164                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17165            })
17166            .collect::<HashMap<_, _>>();
17167        assert!(
17168            !cached_package_probes.is_empty(),
17169            "fixture must exercise package.json lookup"
17170        );
17171        assert!(
17172            cached_package_probes.values().all(|count| *count == 1),
17173            "every package.json path must be probed at most once per cold build"
17174        );
17175        let uncached_package_probes = uncached_memo
17176            .json_probes_for_test()
17177            .into_iter()
17178            .filter(|(path, _)| {
17179                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17180            })
17181            .collect::<HashMap<_, _>>();
17182        let cached_probe_total: usize = cached_package_probes.values().sum();
17183        let uncached_probe_total: usize = uncached_package_probes.values().sum();
17184        assert!(
17185            uncached_probe_total > cached_probe_total * 20,
17186            "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
17187        );
17188    }
17189
17190    #[test]
17191    fn cold_build_disk_file_index_memo_preserves_resolved_rows() {
17192        let dir = tempdir().expect("temp dir");
17193        let project_root = dir.path().join("project");
17194        fs::create_dir_all(&project_root).expect("create project root");
17195        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17196        let files = write_rust_declared_module_memo_fixture(&project_root, 6);
17197
17198        let bypassed = CallGraphStore::open(
17199            dir.path().join("store-disk-memo-bypassed"),
17200            project_root.to_path_buf(),
17201        )
17202        .expect("open bypassed store");
17203        let bypassed_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17204        let bypassed_stats = bypassed
17205            .cold_build_chunked_with_disk_index_memo_for_test(
17206                &files,
17207                3,
17208                7,
17209                &bypassed_module_memo,
17210                false,
17211            )
17212            .expect("build with disk index memo bypassed");
17213
17214        let memoized = CallGraphStore::open(
17215            dir.path().join("store-disk-memoized"),
17216            project_root.to_path_buf(),
17217        )
17218        .expect("open memoized store");
17219        let memoized_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17220        let memoized_stats = memoized
17221            .cold_build_chunked_with_disk_index_memo_for_test(
17222                &files,
17223                3,
17224                7,
17225                &memoized_module_memo,
17226                true,
17227            )
17228            .expect("build with disk index memo enabled");
17229
17230        assert_cold_build_stats_match_except_elapsed(&bypassed_stats, &memoized_stats);
17231        for table in ["refs", "edges"] {
17232            assert_eq!(
17233                graph_table_rows(&bypassed, table),
17234                graph_table_rows(&memoized, table),
17235                "memoized and bypassed disk indexes must produce byte-identical {table} rows"
17236            );
17237        }
17238    }
17239
17240    #[test]
17241    fn rust_declared_module_memo_parses_each_declaring_file_once_and_preserves_edges() {
17242        let dir = tempdir().expect("temp dir");
17243        let project_root = dir.path().join("project");
17244        fs::create_dir_all(&project_root).expect("create project root");
17245        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17246        let files = write_rust_declared_module_memo_fixture(&project_root, 10);
17247
17248        let negative_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17249        for _ in 0..3 {
17250            assert_eq!(
17251                rust_declared_module_target(
17252                    &project_root,
17253                    "src/lib.rs",
17254                    "undeclared",
17255                    &negative_memo,
17256                    &FactPaths {
17257                        root: &project_root,
17258                        facts: &DiskFacts::new(&project_root)
17259                    },
17260                ),
17261                None
17262            );
17263        }
17264        assert_eq!(
17265            negative_memo
17266                .rust_declaration_parses_for_test()
17267                .get("src/lib.rs"),
17268            Some(&1),
17269            "an undeclared module must be retained as a file-level negative result"
17270        );
17271
17272        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
17273        let uncached = CallGraphStore::open(
17274            dir.path().join("rust-store-uncached"),
17275            project_root.to_path_buf(),
17276        )
17277        .expect("open uncached Rust store");
17278        // Bypass the outer disk-index memo so parse counts continue to measure
17279        // the Rust declaration memo rather than the higher-level cache.
17280        let uncached_stats = uncached
17281            .cold_build_chunked_with_disk_index_memo_for_test(&files, 3, 11, &uncached_memo, false)
17282            .expect("uncached Rust build");
17283
17284        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17285        let cached = CallGraphStore::open(
17286            dir.path().join("rust-store-cached"),
17287            project_root.to_path_buf(),
17288        )
17289        .expect("open cached Rust store");
17290        let cached_stats = cached
17291            .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &cached_memo)
17292            .expect("cached Rust build");
17293
17294        assert!(
17295            cached_stats.refs >= 60,
17296            "fixture must exercise repeated qualified and undeclared paths"
17297        );
17298        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17299        assert_eq!(
17300            uncached.edge_snapshot().expect("uncached edge snapshot"),
17301            cached.edge_snapshot().expect("cached edge snapshot"),
17302            "memoized Rust declarations must preserve the public edge set"
17303        );
17304        assert_eq!(
17305            graph_table_rows(&uncached, "edges"),
17306            graph_table_rows(&cached, "edges"),
17307            "source, symbol, target, and provenance rows must be byte-identical"
17308        );
17309
17310        let expected_declaring_files = [
17311            "src/lib.rs",
17312            "src/module_0.rs",
17313            "src/module_1.rs",
17314            "src/module_2.rs",
17315            "src/module_3.rs",
17316            "src/module_4.rs",
17317        ]
17318        .into_iter()
17319        .map(str::to_string)
17320        .collect::<BTreeSet<_>>();
17321        let cached_parses = cached_memo.rust_declaration_parses_for_test();
17322        assert_eq!(
17323            cached_parses.keys().cloned().collect::<BTreeSet<_>>(),
17324            expected_declaring_files,
17325            "the fixture must traverse exactly its six distinct declaring files"
17326        );
17327        assert!(
17328            cached_parses.values().all(|count| *count == 1),
17329            "each declaring file must be parsed once per cold build: {cached_parses:?}"
17330        );
17331
17332        let uncached_parses = uncached_memo.rust_declaration_parses_for_test();
17333        let uncached_parse_total: usize = uncached_parses.values().sum();
17334        let cached_parse_total: usize = cached_parses.values().sum();
17335        println!(
17336            "RUST_DECLARATION_PARSE_COUNTS memo=off:{uncached_parse_total} memo=on:{cached_parse_total} distinct={}",
17337            cached_parses.len()
17338        );
17339        assert!(
17340            uncached_parse_total > 100,
17341            "mutation control: disabling memo insertion must repeat declaration parses; got {uncached_parse_total}"
17342        );
17343        let cached_missing_refs = cached
17344            .conn
17345            .lock()
17346            .expect("callgraph store mutex poisoned")
17347            .query_row(
17348                "SELECT COUNT(*) FROM refs
17349                 WHERE full_ref = 'crate::undeclared::missing' AND target_file IS NULL",
17350                [],
17351                |row| row.get::<_, usize>(0),
17352            )
17353            .expect("count unresolved negative refs");
17354        assert_eq!(
17355            cached_missing_refs, 10,
17356            "all negative-result references must remain unresolved without reparsing"
17357        );
17358    }
17359
17360    #[test]
17361    fn refresh_after_adding_rust_module_declaration_uses_fresh_snapshot() {
17362        let dir = tempdir().expect("temp dir");
17363        let project_root = dir.path().join("project");
17364        fs::create_dir_all(project_root.join("src/custom")).expect("create Rust fixture");
17365        fs::write(
17366            project_root.join("Cargo.toml"),
17367            "[package]\nname = \"refresh-declaration-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17368        )
17369        .expect("write Rust manifest");
17370        let lib = project_root.join("src/lib.rs");
17371        let existing = project_root.join("src/existing.rs");
17372        let added = project_root.join("src/custom/added.rs");
17373        fs::write(
17374            &lib,
17375            "mod existing;\npub fn run() { crate::added::target(); }\n",
17376        )
17377        .expect("write initial lib");
17378        fs::write(&existing, "pub fn existing() {}\n").expect("write existing module");
17379        fs::write(&added, "pub fn target() {}\n").expect("write added module target");
17380        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17381        let lib = project_root.join("src/lib.rs");
17382        let existing = project_root.join("src/existing.rs");
17383        let added = project_root.join("src/custom/added.rs");
17384
17385        let store = CallGraphStore::open(
17386            dir.path().join("refresh-declaration-store"),
17387            project_root.to_path_buf(),
17388        )
17389        .expect("open refresh store");
17390        store
17391            .cold_build(&[lib.clone(), existing.clone(), added])
17392            .expect("initial cold build");
17393        assert!(
17394            store
17395                .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17396                .expect("initial callers")
17397                .is_empty(),
17398            "the custom-path module must be unresolved before its declaration exists"
17399        );
17400
17401        fs::write(&existing, "pub fn existing() { let _ = 1; }\n").expect("touch existing module");
17402        store
17403            .refresh_files(std::slice::from_ref(&existing))
17404            .expect("warm refresh declaration loading");
17405        fs::write(
17406            &lib,
17407            "mod existing;\n#[path = \"custom/added.rs\"]\nmod added;\npub fn run() { crate::added::target(); }\n",
17408        )
17409        .expect("add custom-path module declaration");
17410        store
17411            .refresh_files(std::slice::from_ref(&lib))
17412            .expect("refresh declaring file");
17413
17414        let callers = store
17415            .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17416            .expect("refreshed callers");
17417        assert!(
17418            callers
17419                .iter()
17420                .any(|site| { site.caller.file == "src/lib.rs" && site.caller.symbol == "run" }),
17421            "a fresh refresh generation must resolve the newly declared module: {callers:#?}"
17422        );
17423    }
17424
17425    // Benchmark the cold resolver with and without memoization. The generated
17426    // workspace has hundreds of TypeScript files below a deep package-manifest
17427    // ladder and enough imported calls for filesystem resolution to dominate
17428    // the uncached run.
17429    #[test]
17430    #[ignore]
17431    fn bench_cold_build_resolution_memo() {
17432        let dir = tempdir().expect("temp dir");
17433        let project_root = dir.path().join("project");
17434        fs::create_dir_all(&project_root).expect("create benchmark root");
17435        let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
17436        let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
17437        assert!(
17438            files.len() > 250,
17439            "benchmark fixture must contain hundreds of files"
17440        );
17441
17442        for enabled in [false, true] {
17443            callgraph::clear_workspace_package_cache();
17444            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
17445            let store = CallGraphStore::open(
17446                dir.path().join(if enabled {
17447                    "store-cached"
17448                } else {
17449                    "store-uncached"
17450                }),
17451                project_root.to_path_buf(),
17452            )
17453            .expect("open benchmark store");
17454            let cpu_started = process_cpu_time();
17455            let wall_started = Instant::now();
17456            let stats = store
17457                .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
17458                .expect("benchmark cold build");
17459            let wall_ms = wall_started.elapsed().as_millis();
17460            let cpu_ms = process_cpu_time()
17461                .checked_sub(cpu_started)
17462                .unwrap_or_default()
17463                .as_millis();
17464            println!(
17465                "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
17466                if enabled { "on" } else { "off" },
17467                stats.files,
17468                stats.refs,
17469                stats.edges,
17470                wall_ms,
17471                cpu_ms
17472            );
17473        }
17474    }
17475
17476    #[test]
17477    #[ignore]
17478    fn bench_rust_declared_module_memo_real_corpus() {
17479        let project_root = fs::canonicalize(env!("CARGO_MANIFEST_DIR"))
17480            .expect("canonical agent-file-tools crate root");
17481        let files = [
17482            "src/main.rs",
17483            "src/cli/mod.rs",
17484            "src/cli/index.rs",
17485            "src/cli/sandbox_launch.rs",
17486            "src/cli/warmup.rs",
17487        ]
17488        .into_iter()
17489        .map(|path| project_root.join(path))
17490        .collect::<Vec<_>>();
17491        assert!(
17492            files.iter().all(|path| path.is_file()),
17493            "real-corpus benchmark sources must exist"
17494        );
17495        let dir = tempdir().expect("benchmark temp dir");
17496        let mut baseline_edges = None;
17497        let mut baseline_stats = None;
17498        let enabled_modes = match std::env::var("AFT_RUST_DECL_MEMO").as_deref() {
17499            Ok("off") => vec![false],
17500            Ok("on") => vec![true],
17501            _ => vec![false, true],
17502        };
17503
17504        for enabled in enabled_modes {
17505            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, true);
17506            let store = CallGraphStore::open(
17507                dir.path().join(if enabled {
17508                    "rust-real-cached"
17509                } else {
17510                    "rust-real-uncached"
17511                }),
17512                project_root.to_path_buf(),
17513            )
17514            .expect("open real-corpus store");
17515            let phase_times = Arc::new(Mutex::new((None, None)));
17516            let observer_times = Arc::clone(&phase_times);
17517            set_cold_build_phase_observer(Some(Arc::new(move |phase| {
17518                let mut times = observer_times.lock().expect("phase timing mutex poisoned");
17519                match phase {
17520                    "resolution" if times.0.is_none() => times.0 = Some(Instant::now()),
17521                    "publication" if times.1.is_none() => times.1 = Some(Instant::now()),
17522                    _ => {}
17523                }
17524            })));
17525            let build = store.cold_build_chunked_with_resolution_memo_for_test(
17526                &files,
17527                COLD_BUILD_EXTRACT_BATCH_FILES,
17528                COLD_BUILD_RESOLVE_WINDOW,
17529                &memo,
17530            );
17531            set_cold_build_phase_observer(None);
17532            let stats = build.expect("real-corpus cold build");
17533            let times = phase_times.lock().expect("phase timing mutex poisoned");
17534            let resolution_elapsed = times
17535                .1
17536                .expect("publication phase timestamp")
17537                .duration_since(times.0.expect("resolution phase timestamp"));
17538            drop(times);
17539            let edges = graph_table_rows(&store, "edges");
17540            if let Some(expected) = &baseline_edges {
17541                assert_eq!(
17542                    &edges, expected,
17543                    "real-corpus edge rows, including provenance, must be byte-identical"
17544                );
17545            } else {
17546                baseline_edges = Some(edges);
17547            }
17548            let stats_tuple = (stats.files, stats.nodes, stats.refs, stats.edges);
17549            if let Some(expected) = baseline_stats {
17550                assert_eq!(stats_tuple, expected, "real-corpus build counts must match");
17551            } else {
17552                baseline_stats = Some(stats_tuple);
17553            }
17554            let parses = memo.rust_declaration_parses_for_test();
17555            println!(
17556                "BENCH_RUST_DECLARED_MODULE_MEMO memo={} files={} refs={} edges={} resolution_wall_ms={} declaration_parses={} distinct_declaring_files={}",
17557                if enabled { "on" } else { "off" },
17558                stats.files,
17559                stats.refs,
17560                stats.edges,
17561                resolution_elapsed.as_millis(),
17562                parses.values().sum::<usize>(),
17563                parses.len()
17564            );
17565        }
17566    }
17567
17568    // Perf A/B bench (not a gate): measures cold_build wall time at a given
17569    // chunk size against a real repo. Driven by env so the same binary can A/B
17570    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
17571    // memory work. Run:
17572    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
17573    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
17574    #[test]
17575    #[ignore]
17576    fn bench_cold_build_chunk() {
17577        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
17578        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
17579            .expect("AFT_PERF_CHUNK")
17580            .parse()
17581            .expect("AFT_PERF_CHUNK must be a non-negative integer");
17582        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
17583        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
17584        let dir = tempdir().expect("temp dir");
17585        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
17586            .expect("open store");
17587        let started = Instant::now();
17588        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
17589        let ms = started.elapsed().as_millis();
17590        println!(
17591            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
17592            stats.files, stats.nodes, stats.refs, stats.edges
17593        );
17594    }
17595
17596    #[test]
17597    fn persisted_workspace_reexport_selects_its_package_dependency() {
17598        let root = tempdir().expect("temp dir");
17599        let dependencies = BTreeSet::from([
17600            "packages/aft-bridge/src/index.ts".to_string(),
17601            "packages/opencode-plugin/src/types.ts".to_string(),
17602        ]);
17603        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
17604
17605        assert_eq!(
17606            stored_dependencies_for_module(
17607                root.path(),
17608                "packages/opencode-plugin/src/shared/bash-hints.ts",
17609                "@cortexkit/aft-bridge",
17610                &dependencies,
17611                &indexed_files,
17612                &FactPaths {
17613                    root: root.path(),
17614                    facts: &DiskFacts::new(root.path())
17615                }
17616            ),
17617            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
17618        );
17619    }
17620
17621    #[test]
17622    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
17623        let dir = tempdir().expect("temp dir");
17624        let project_root = dir.path();
17625        let files =
17626            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
17627        let index_path = project_root.join("src/index.ts");
17628
17629        let store = CallGraphStore::open(
17630            project_root.join(".store-incremental-barrel"),
17631            project_root.to_path_buf(),
17632        )
17633        .expect("open incremental store");
17634        store.cold_build(&files).expect("initial cold build");
17635
17636        {
17637            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
17638            let tx = conn.transaction().expect("dependency transaction");
17639            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
17640                .expect("dependent refs for barrel");
17641            let selected_ref_ids = dependent_refs
17642                .iter()
17643                .map(|dependent_ref| dependent_ref.ref_id.clone())
17644                .collect::<BTreeSet<_>>();
17645            let mut threaded_ref_ids = BTreeSet::new();
17646            let mut threaded_by_caller = BTreeMap::new();
17647            record_dependent_refs(
17648                &mut threaded_ref_ids,
17649                &mut threaded_by_caller,
17650                dependent_refs,
17651            );
17652            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
17653                .expect("old per-ref caller lookup");
17654
17655            assert_eq!(threaded_ref_ids, selected_ref_ids);
17656            assert_eq!(threaded_by_caller, old_by_caller);
17657            for consumer in [
17658                "src/consumer_a.ts",
17659                "src/consumer_b.ts",
17660                "src/consumer_c.ts",
17661            ] {
17662                assert!(
17663                    threaded_by_caller.contains_key(consumer),
17664                    "barrel edit should select dependent refs from {consumer}"
17665                );
17666            }
17667        }
17668
17669        fs::write(
17670            &index_path,
17671            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
17672        )
17673        .expect("edit barrel");
17674        let stats = store
17675            .refresh_files(std::slice::from_ref(&index_path))
17676            .expect("incremental refresh");
17677        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
17678        assert!(
17679            stats.dependency_selected_refs > 0,
17680            "barrel surface edit should select dependent refs"
17681        );
17682
17683        let cold_store = CallGraphStore::open(
17684            project_root.join(".store-cold-barrel"),
17685            project_root.to_path_buf(),
17686        )
17687        .expect("open cold rebuild store");
17688        cold_store
17689            .cold_build(&files)
17690            .expect("comparison cold build");
17691
17692        for table in [
17693            "nodes",
17694            "refs",
17695            "file_dependencies",
17696            "edges",
17697            "dispatch_hints",
17698        ] {
17699            assert_eq!(
17700                graph_table_rows(&store, table),
17701                graph_table_rows(&cold_store, table),
17702                "incremental refresh {table} rows must match cold rebuild"
17703            );
17704        }
17705
17706        let consumer_path = project_root.join("src/consumer_a.ts");
17707        fs::write(
17708            &consumer_path,
17709            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
17710        )
17711        .expect("edit barrel consumer");
17712        store
17713            .refresh_files(std::slice::from_ref(&consumer_path))
17714            .expect("refresh consumer through unchanged barrel");
17715        cold_store
17716            .cold_build(&files)
17717            .expect("comparison cold rebuild after consumer refresh");
17718        for table in [
17719            "nodes",
17720            "refs",
17721            "file_dependencies",
17722            "edges",
17723            "dispatch_hints",
17724        ] {
17725            assert_eq!(
17726                graph_table_rows(&store, table),
17727                graph_table_rows(&cold_store, table),
17728                "refresh through a persisted barrel must preserve cold-build {table} rows"
17729            );
17730        }
17731    }
17732
17733    fn build_reference_connection(
17734        project_root: &Path,
17735        extract: &FileExtract,
17736        resolved: &ResolvedRef,
17737    ) -> Connection {
17738        let mut conn = Connection::open_in_memory().expect("open reference db");
17739        configure_build_connection(&conn).expect("configure reference db");
17740        initialize_schema(&conn).expect("initialize reference schema");
17741        {
17742            let tx = conn.transaction().expect("reference transaction");
17743            clear_tables(&tx).expect("reference clear");
17744            insert_meta(&tx).expect("reference meta");
17745            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
17746            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
17747            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17748                .expect("reference dispatch edges");
17749            assert_eq!(supplemental, 0);
17750            tx.commit().expect("reference commit");
17751        }
17752        conn
17753    }
17754
17755    fn build_optimized_connection(
17756        project_root: &Path,
17757        extract: &FileExtract,
17758        resolved: &ResolvedRef,
17759    ) -> Connection {
17760        let mut conn = Connection::open_in_memory().expect("open optimized db");
17761        configure_build_connection(&conn).expect("configure optimized db");
17762        initialize_schema(&conn).expect("initialize optimized schema");
17763        {
17764            let tx = conn.transaction().expect("optimized transaction");
17765            clear_tables(&tx).expect("optimized clear");
17766            insert_meta(&tx).expect("optimized meta");
17767            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
17768            {
17769                let workspace_root = project_root.display().to_string();
17770                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
17771                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
17772                    .expect("optimized file extract");
17773                insert_resolved_ref_prepared(&mut inserts, resolved)
17774                    .expect("optimized resolved ref");
17775            }
17776            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
17777            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17778                .expect("optimized dispatch edges");
17779            assert_eq!(supplemental, 0);
17780            tx.commit().expect("optimized commit");
17781        }
17782        conn
17783    }
17784
17785    fn fixture_extract(_project_root: &Path) -> FileExtract {
17786        let rel_path = "src/main.ts".to_string();
17787        let target_path = "src/helper.ts".to_string();
17788        let node = NodeRecord {
17789            id: "node-main".to_string(),
17790            file_path: rel_path.clone(),
17791            name: "main".to_string(),
17792            scoped_name: "main".to_string(),
17793            kind: "function".to_string(),
17794            range: Range {
17795                start_line: 0,
17796                start_col: 0,
17797                end_line: 0,
17798                end_col: 32,
17799            },
17800            range_ordinal: 0,
17801            signature: Some("export function main()".to_string()),
17802            exported: true,
17803            is_default_export: false,
17804            is_type_like: false,
17805            is_callgraph_entry_point: true,
17806        };
17807        let mut dependencies = BTreeSet::new();
17808        dependencies.insert(target_path.clone());
17809        let raw_ref = RawRef {
17810            ref_id: "ref-main-helper".to_string(),
17811            caller_node: Some(node.id.clone()),
17812            caller_symbol: Some(node.scoped_name.clone()),
17813            caller_file: rel_path.clone(),
17814            kind: "call".to_string(),
17815            short_name: Some("helper".to_string()),
17816            full_ref: Some("helper".to_string()),
17817            module_path: None,
17818            import_kind: None,
17819            local_name: Some("helper".to_string()),
17820            requested_name: Some("helper".to_string()),
17821            namespace_alias: None,
17822            wildcard: false,
17823            line: 1,
17824            byte_start: 24,
17825            byte_end: 32,
17826            dependencies,
17827        };
17828        FileExtract {
17829            rel_path,
17830            freshness: FileFreshness {
17831                mtime: UNIX_EPOCH + Duration::from_secs(123),
17832                size: 40,
17833                content_hash: cache_freshness::hash_bytes(b"fixture source"),
17834            },
17835            lang: LangId::TypeScript,
17836            data: FileCallData {
17837                calls_by_symbol: HashMap::new(),
17838                value_refs_by_symbol: HashMap::new(),
17839                exported_symbols: Vec::new(),
17840                symbol_metadata: HashMap::new(),
17841                default_export_symbol: None,
17842                import_block: ImportBlock::empty(),
17843                lang: LangId::TypeScript,
17844            },
17845            nodes: vec![node.clone()],
17846            raw_refs: vec![raw_ref],
17847            dispatch_hints: vec![DispatchHint {
17848                id: "dispatch-main-helper".to_string(),
17849                method_name: "helper".to_string(),
17850                caller_node: node.id,
17851                file: "src/main.ts".to_string(),
17852                line: 1,
17853                byte_start: 24,
17854                byte_end: 32,
17855            }],
17856            surface_fingerprint: "surface".to_string(),
17857        }
17858    }
17859
17860    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
17861        let raw = extract.raw_refs[0].clone();
17862        let mut dependencies = raw.dependencies.clone();
17863        dependencies.insert("src/helper.ts".to_string());
17864        ResolvedRef {
17865            edge: Some(EdgeRecord {
17866                edge_id: "edge-main-helper".to_string(),
17867                source_node: raw.caller_node.clone().expect("caller node"),
17868                target_node: Some("node-helper".to_string()),
17869                target_file: "src/helper.ts".to_string(),
17870                target_symbol: "helper".to_string(),
17871                kind: "call".to_string(),
17872                line: raw.line,
17873            }),
17874            raw,
17875            status: "resolved".to_string(),
17876            target_node: Some("node-helper".to_string()),
17877            target_file: Some("src/helper.ts".to_string()),
17878            target_symbol: Some("helper".to_string()),
17879            dependencies,
17880        }
17881    }
17882
17883    fn write_rust_declared_module_memo_fixture(
17884        project_root: &Path,
17885        calls_per_module: usize,
17886    ) -> Vec<PathBuf> {
17887        fs::create_dir_all(project_root.join("src")).expect("create Rust fixture root");
17888        fs::write(
17889            project_root.join("Cargo.toml"),
17890            "[package]\nname = \"rust-declaration-memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17891        )
17892        .expect("write Rust fixture manifest");
17893
17894        let modules = (0..5)
17895            .map(|index| format!("module_{index}"))
17896            .collect::<Vec<_>>();
17897        let mut lib_source = modules
17898            .iter()
17899            .map(|module| format!("pub mod {module};\n"))
17900            .collect::<String>();
17901        lib_source.push_str("\npub fn dispatch() {\n");
17902        for call in 0..calls_per_module {
17903            for (index, module) in modules.iter().enumerate() {
17904                lib_source.push_str(&format!(
17905                    "    crate::{module}::leaf::target_{index}(); // call {call}\n"
17906                ));
17907            }
17908            lib_source.push_str("    crate::undeclared::missing();\n");
17909        }
17910        lib_source.push_str("}\n");
17911        let lib = project_root.join("src/lib.rs");
17912        fs::write(&lib, lib_source).expect("write Rust fixture lib");
17913        let mut files = vec![lib];
17914
17915        for (index, module) in modules.iter().enumerate() {
17916            let declaring_file = project_root.join(format!("src/{module}.rs"));
17917            fs::write(&declaring_file, "pub mod leaf;\n").expect("write nested module declaration");
17918            let target_file = project_root.join(format!("src/{module}/leaf.rs"));
17919            fs::create_dir_all(target_file.parent().expect("nested module parent"))
17920                .expect("create nested module directory");
17921            fs::write(&target_file, format!("pub fn target_{index}() {{}}\n"))
17922                .expect("write nested module target");
17923            files.push(declaring_file);
17924            files.push(target_file);
17925        }
17926        files
17927    }
17928
17929    fn write_ts_resolution_memo_fixture(
17930        project_root: &Path,
17931        package_count: usize,
17932        files_per_package: usize,
17933        calls_per_file: usize,
17934    ) -> Vec<PathBuf> {
17935        fs::create_dir_all(project_root).expect("create memo fixture root");
17936        fs::write(
17937            project_root.join("package.json"),
17938            r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
17939        )
17940        .expect("write workspace package manifest");
17941        fs::write(
17942            project_root.join("tsconfig.json"),
17943            r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
17944        )
17945        .expect("write fixture tsconfig");
17946
17947        let shared_root = project_root.join("packages/shared");
17948        let shared_source = shared_root.join("src/index.ts");
17949        fs::create_dir_all(shared_source.parent().expect("shared source parent"))
17950            .expect("create shared package");
17951        fs::write(
17952            shared_root.join("package.json"),
17953            r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
17954        )
17955        .expect("write shared package manifest");
17956        fs::write(
17957            &shared_source,
17958            "export function shared(value: number) { return value + 1; }\n",
17959        )
17960        .expect("write shared source");
17961        let mut files = vec![shared_source];
17962
17963        for package in 0..package_count {
17964            let package_root = project_root.join(format!("packages/app-{package:02}"));
17965            fs::create_dir_all(&package_root).expect("create app package");
17966            fs::write(
17967                package_root.join("package.json"),
17968                format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
17969            )
17970            .expect("write app package manifest");
17971            let source_dir = package_root.join("src/features/deep/nested/leaf");
17972            fs::create_dir_all(&source_dir).expect("create deep app source dir");
17973
17974            for file in 0..files_per_package {
17975                let source_path = source_dir.join(format!("caller_{file:03}.ts"));
17976                let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
17977                for call in 0..calls_per_file {
17978                    source.push_str(&format!(
17979                        "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
17980                    ));
17981                }
17982                fs::write(&source_path, source).expect("write app source");
17983                files.push(source_path);
17984            }
17985        }
17986
17987        files
17988    }
17989
17990    #[cfg(unix)]
17991    fn process_cpu_time() -> Duration {
17992        let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
17993        let result =
17994            unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
17995        if result != 0 {
17996            return Duration::ZERO;
17997        }
17998        let value = unsafe { value.assume_init() };
17999        Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
18000    }
18001
18002    #[cfg(not(unix))]
18003    fn process_cpu_time() -> Duration {
18004        Duration::ZERO
18005    }
18006
18007    fn write_chunked_equivalence_fixture(project_root: &Path) {
18008        let ts_dir = project_root.join("ts");
18009        fs::create_dir_all(&ts_dir).expect("create ts dir");
18010        fs::write(
18011            ts_dir.join("leaf.ts"),
18012            "export function leaf(value: number) {\n  return value + 1;\n}\n",
18013        )
18014        .expect("write ts leaf");
18015        fs::write(
18016            ts_dir.join("mid.ts"),
18017            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
18018        )
18019        .expect("write ts mid");
18020        fs::write(
18021            ts_dir.join("entry.ts"),
18022            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
18023        )
18024        .expect("write ts entry");
18025        fs::write(
18026            ts_dir.join("worker.ts"),
18027            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
18028        )
18029        .expect("write ts worker");
18030        for idx in 0..4 {
18031            fs::write(
18032                ts_dir.join(format!("extra_{idx}.ts")),
18033                format!(
18034                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
18035                ),
18036            )
18037            .expect("write ts extra");
18038        }
18039
18040        let rust_dir = project_root.join("src");
18041        let commands_dir = rust_dir.join("commands");
18042        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
18043        fs::write(
18044            rust_dir.join("context.rs"),
18045            r#"pub struct AppContext;
18046
18047impl AppContext {
18048    pub fn callgraph_store_for_ops(&self) -> usize {
18049        1
18050    }
18051}
18052"#,
18053        )
18054        .expect("write rust context");
18055        fs::write(
18056            rust_dir.join("lib.rs"),
18057            "pub mod context;\npub mod commands;\n",
18058        )
18059        .expect("write rust lib");
18060        fs::write(
18061            commands_dir.join("mod.rs"),
18062            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
18063        )
18064        .expect("write rust commands mod");
18065        for name in ["callers", "impact", "trace_to"] {
18066            fs::write(
18067                commands_dir.join(format!("{name}.rs")),
18068                format!(
18069                    r#"use crate::context::AppContext;
18070
18071pub fn handle_{name}(ctx: &AppContext) -> usize {{
18072    ctx.callgraph_store_for_ops()
18073}}
18074"#
18075                ),
18076            )
18077            .expect("write rust command");
18078        }
18079    }
18080
18081    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
18082        let src_dir = project_root.join("src");
18083        fs::create_dir_all(&src_dir).expect("create src dir");
18084
18085        let target_path = src_dir.join("target.ts");
18086        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
18087            .expect("write target");
18088
18089        let index_path = src_dir.join("index.ts");
18090        fs::write(&index_path, barrel_source).expect("write barrel");
18091
18092        let mut files = vec![target_path, index_path];
18093        for (file_name, function_name) in [
18094            ("consumer_a.ts", "consumerA"),
18095            ("consumer_b.ts", "consumerB"),
18096            ("consumer_c.ts", "consumerC"),
18097        ] {
18098            let path = src_dir.join(file_name);
18099            fs::write(
18100                &path,
18101                format!(
18102                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
18103                ),
18104            )
18105            .expect("write consumer");
18106            files.push(path);
18107        }
18108        files
18109    }
18110
18111    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
18112        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18113        table_rows(&conn, table)
18114    }
18115
18116    fn graph_table_rows_without(
18117        store: &CallGraphStore,
18118        table: &str,
18119        excluded_columns: &[&str],
18120    ) -> Vec<String> {
18121        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18122        table_rows_without(&conn, table, excluded_columns)
18123    }
18124
18125    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
18126        table_rows_without(conn, table, &[])
18127    }
18128
18129    fn table_rows_without(
18130        conn: &Connection,
18131        table: &str,
18132        excluded_columns: &[&str],
18133    ) -> Vec<String> {
18134        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
18135        let columns: Vec<String> = conn
18136            .prepare(&format!("PRAGMA table_info({table})"))
18137            .expect("prepare table_info")
18138            .query_map([], |row| row.get::<_, String>(1))
18139            .expect("query table_info")
18140            .collect::<std::result::Result<Vec<String>, _>>()
18141            .expect("collect columns")
18142            .into_iter()
18143            .filter(|column| !excluded_columns.contains(column.as_str()))
18144            .collect();
18145        let sql = format!(
18146            "SELECT {} FROM {table} ORDER BY {}",
18147            columns.join(", "),
18148            columns.join(", ")
18149        );
18150        conn.prepare(&sql)
18151            .expect("prepare table rows")
18152            .query_map([], |row| row_to_strings(row, columns.len()))
18153            .expect("query table rows")
18154            .collect::<std::result::Result<_, _>>()
18155            .expect("collect table rows")
18156    }
18157
18158    fn assert_cold_build_stats_match_except_elapsed(
18159        expected: &ColdBuildStats,
18160        actual: &ColdBuildStats,
18161    ) {
18162        assert_eq!(actual.files, expected.files, "file counts must match");
18163        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
18164        assert_eq!(actual.refs, expected.refs, "ref counts must match");
18165        assert_eq!(actual.edges, expected.edges, "edge counts must match");
18166        assert_eq!(
18167            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
18168            expected
18169                .failed_files
18170                .iter()
18171                .cloned()
18172                .collect::<BTreeSet<_>>(),
18173            "failed file sets must match"
18174        );
18175    }
18176
18177    fn backend_state_rows(conn: &Connection) -> Vec<String> {
18178        conn.prepare(
18179            "SELECT backend, workspace_root, file_path, content_hash, status
18180             FROM backend_file_state
18181             ORDER BY backend, workspace_root, file_path, content_hash, status",
18182        )
18183        .expect("prepare backend rows")
18184        .query_map([], |row| row_to_strings(row, 5))
18185        .expect("query backend rows")
18186        .collect::<std::result::Result<_, _>>()
18187        .expect("collect backend rows")
18188    }
18189
18190    fn secondary_indexes(conn: &Connection) -> Vec<String> {
18191        let mut indexes = Vec::new();
18192        for table in [
18193            "files",
18194            "nodes",
18195            "refs",
18196            "file_dependencies",
18197            "edges",
18198            "dispatch_hints",
18199            "type_ref_names",
18200            "backend_file_state",
18201            "meta",
18202        ] {
18203            let sql = format!("PRAGMA index_list({table})");
18204            let mut stmt = conn.prepare(&sql).expect("prepare index list");
18205            let rows = stmt
18206                .query_map([], |row| row.get::<_, String>(1))
18207                .expect("query index list");
18208            for name in rows {
18209                let name = name.expect("index name");
18210                if name.starts_with("idx_") {
18211                    indexes.push(format!("{table}:{name}"));
18212                }
18213            }
18214        }
18215        indexes.sort();
18216        indexes
18217    }
18218
18219    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
18220        let mut values = Vec::with_capacity(len);
18221        for index in 0..len {
18222            let value = row.get_ref(index)?;
18223            values.push(match value {
18224                rusqlite::types::ValueRef::Null => "NULL".to_string(),
18225                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
18226                rusqlite::types::ValueRef::Real(value) => value.to_string(),
18227                rusqlite::types::ValueRef::Text(value) => {
18228                    String::from_utf8_lossy(value).into_owned()
18229                }
18230                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
18231            });
18232        }
18233        Ok(values.join("\u{1f}"))
18234    }
18235}
18236
18237#[cfg(test)]
18238mod rust_resolution_tests {
18239    use super::*;
18240    use crate::inspect::job::CallgraphSnapshot;
18241    use std::fs;
18242    use tempfile::tempdir;
18243
18244    #[test]
18245    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
18246        let dir = tempdir().expect("tempdir");
18247        let root = dir.path();
18248        write_rust_manifest(root, "scoped-alias-fixture");
18249        write_file(
18250            root,
18251            "src/lib.rs",
18252            r#"pub mod finalization_contract;
18253
18254pub fn run_alias() {
18255    use crate::finalization_contract as fc;
18256    fc::check_mason_contract();
18257}
18258"#,
18259        );
18260        write_file(
18261            root,
18262            "src/finalization_contract.rs",
18263            r#"pub fn check_mason_contract() {}
18264fn planted_dead() {}
18265"#,
18266        );
18267
18268        let (store, snapshot) = cold_build_twice(root);
18269        assert_direct_caller(
18270            &store,
18271            "src/finalization_contract.rs",
18272            "check_mason_contract",
18273            "src/lib.rs",
18274            "run_alias",
18275        );
18276        assert_projected_call(
18277            root,
18278            &snapshot,
18279            "src/finalization_contract.rs",
18280            "check_mason_contract",
18281        );
18282        assert_no_projected_call(
18283            root,
18284            &snapshot,
18285            "src/finalization_contract.rs",
18286            "planted_dead",
18287        );
18288        assert!(
18289            store
18290                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
18291                .expect("planted dead callers")
18292                .is_empty(),
18293            "planted-dead guard should stay without callers"
18294        );
18295    }
18296
18297    #[test]
18298    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
18299        let dir = tempdir().expect("tempdir");
18300        let root = dir.path();
18301        write_rust_manifest(root, "inline-module-fixture");
18302        write_file(
18303            root,
18304            "src/lib.rs",
18305            r#"mod work_graph { fn operations() {} }
18306mod manifest { fn operations() {} }
18307mod audit { fn operations() {} }
18308mod dispatch { fn operations() {} }
18309mod finalization { fn operations() {} }
18310
18311pub fn run_inline_operations() {
18312    work_graph::operations();
18313    manifest::operations();
18314    audit::operations();
18315    dispatch::operations();
18316    finalization::operations();
18317}
18318
18319fn planted_dead() {}
18320"#,
18321        );
18322
18323        let (store, snapshot) = cold_build_twice(root);
18324        for module in [
18325            "work_graph",
18326            "manifest",
18327            "audit",
18328            "dispatch",
18329            "finalization",
18330        ] {
18331            assert_direct_caller(
18332                &store,
18333                "src/lib.rs",
18334                &format!("{module}::operations"),
18335                "src/lib.rs",
18336                "run_inline_operations",
18337            );
18338        }
18339        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
18340        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18341    }
18342
18343    #[test]
18344    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
18345        let dir = tempdir().expect("tempdir");
18346        let root = dir.path();
18347        fs::write(
18348            root.join("Cargo.toml"),
18349            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
18350        )
18351        .expect("write workspace manifest");
18352        write_file(
18353            root,
18354            "crates/but-action/Cargo.toml",
18355            r#"[package]
18356name = "but-action"
18357version = "0.1.0"
18358edition = "2021"
18359"#,
18360        );
18361        write_file(
18362            root,
18363            "crates/but-action/src/lib.rs",
18364            "mod action;\npub use action::{list_actions};\n",
18365        );
18366        write_file(
18367            root,
18368            "crates/but-action/src/action.rs",
18369            "pub fn list_actions() {}\nfn planted_dead() {}\n",
18370        );
18371        write_file(
18372            root,
18373            "crates/app/Cargo.toml",
18374            r#"[package]
18375name = "app"
18376version = "0.1.0"
18377edition = "2021"
18378"#,
18379        );
18380        write_file(
18381            root,
18382            "crates/app/src/lib.rs",
18383            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
18384        );
18385
18386        let (store, snapshot) = cold_build_twice(root);
18387        assert_direct_caller(
18388            &store,
18389            "crates/but-action/src/action.rs",
18390            "list_actions",
18391            "crates/app/src/lib.rs",
18392            "run_actions",
18393        );
18394        assert!(
18395            store
18396                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
18397                .expect("lib reexport callers")
18398                .is_empty(),
18399            "call should target the reexported source function, not lib.rs"
18400        );
18401        assert_projected_call(
18402            root,
18403            &snapshot,
18404            "crates/but-action/src/action.rs",
18405            "list_actions",
18406        );
18407        assert_no_projected_call(
18408            root,
18409            &snapshot,
18410            "crates/but-action/src/action.rs",
18411            "planted_dead",
18412        );
18413    }
18414
18415    #[test]
18416    fn rust_cfg_attributed_module_resolves_outgoing_calls() {
18417        let dir = tempdir().expect("tempdir");
18418        let root = dir.path();
18419        write_rust_manifest(root, "cfg-module-outgoing-fixture");
18420        write_file(
18421            root,
18422            "src/lib.rs",
18423            "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
18424        );
18425        for module in ["conformance", "ordinary"] {
18426            write_file(
18427                root,
18428                &format!("src/{module}.rs"),
18429                "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n    local_target();\n    project_range();\n}\n",
18430            );
18431        }
18432
18433        let (store, _) = cold_build_twice(root);
18434        for module in ["conformance", "ordinary"] {
18435            assert_direct_caller(
18436                &store,
18437                &format!("src/{module}.rs"),
18438                "local_target",
18439                &format!("src/{module}.rs"),
18440                "run",
18441            );
18442            assert_direct_caller(
18443                &store,
18444                "src/lib.rs",
18445                "project_range",
18446                &format!("src/{module}.rs"),
18447                "run",
18448            );
18449        }
18450    }
18451
18452    #[test]
18453    fn rust_registered_modules_preserve_import_alias_resolution() {
18454        let dir = tempdir().expect("tempdir");
18455        let root = dir.path();
18456        write_rust_manifest(root, "registered-module-import-control");
18457        write_file(
18458            root,
18459            "src/main.rs",
18460            "mod commands;\nmod db;\nfn main() {}\n",
18461        );
18462        write_file(
18463            root,
18464            "src/commands.rs",
18465            "use crate::db;\n\npub fn run() {\n    db::helper();\n}\n",
18466        );
18467        write_file(root, "src/db.rs", "pub fn helper() {}\n");
18468
18469        let main_extract =
18470            build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
18471        let commands_extract =
18472            build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
18473        let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
18474        let files = [&main_extract, &commands_extract, &db_extract]
18475            .into_iter()
18476            .map(|extract| {
18477                (
18478                    extract.rel_path.clone(),
18479                    DbFileIndex::from_extract(
18480                        root,
18481                        extract,
18482                        &FactPaths {
18483                            root,
18484                            facts: &DiskFacts::new(root),
18485                        },
18486                    ),
18487                )
18488            })
18489            .collect::<HashMap<_, _>>();
18490        let caller_data = [&main_extract, &commands_extract, &db_extract]
18491            .into_iter()
18492            .map(|extract| (extract.rel_path.clone(), &extract.data))
18493            .collect::<HashMap<_, _>>();
18494        let index = ProjectIndex::from_parts(
18495            root,
18496            files,
18497            caller_data,
18498            WorkspaceCratePrefixCache::default(),
18499            Rc::new(DiskFacts::new(root)),
18500        );
18501        assert_eq!(
18502            index.module_parent("src/commands.rs"),
18503            Some(("src/main.rs".to_string(), "commands".to_string()))
18504        );
18505        assert_eq!(
18506            index.module_target("src/main.rs", "db").as_deref(),
18507            Some("src/db.rs")
18508        );
18509        let call = commands_extract
18510            .raw_refs
18511            .iter()
18512            .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
18513            .expect("db helper call")
18514            .clone();
18515        let resolved = resolve_ref(call, &index).expect("resolve db helper");
18516        assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
18517        assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
18518
18519        let (store, _) = cold_build_twice(root);
18520        assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
18521    }
18522
18523    #[test]
18524    fn rust_path_attributed_module_uses_declared_logical_parent() {
18525        let dir = tempdir().expect("tempdir");
18526        let root = dir.path();
18527        write_rust_manifest(root, "path-module-outgoing-fixture");
18528        write_file(
18529            root,
18530            "src/lib.rs",
18531            "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
18532        );
18533        write_file(
18534            root,
18535            "src/alternate/custom.rs",
18536            "pub fn run() {\n    super::project_range();\n}\n",
18537        );
18538
18539        let (store, _) = cold_build_twice(root);
18540        assert_direct_caller(
18541            &store,
18542            "src/lib.rs",
18543            "project_range",
18544            "src/alternate/custom.rs",
18545            "run",
18546        );
18547    }
18548
18549    #[test]
18550    fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
18551        let dir = tempdir().expect("tempdir");
18552        let root = dir.path();
18553        write_rust_manifest(root, "same-file-test-module-fixture");
18554        write_file(
18555            root,
18556            "src/lib.rs",
18557            r#"pub struct Index(u32);
18558
18559impl Index {
18560    pub fn shares_index_with(&self, other: &Self) -> bool {
18561        self.0 == other.0
18562    }
18563}
18564
18565#[cfg(test)]
18566mod tests {
18567    use super::Index;
18568
18569    #[test]
18570    fn compares_indexes() {
18571        let before = Index(1);
18572        let after = Index(1);
18573        assert!(before.shares_index_with(&after));
18574    }
18575}
18576"#,
18577        );
18578
18579        let (store, snapshot) = cold_build_twice(root);
18580        assert_direct_caller(
18581            &store,
18582            "src/lib.rs",
18583            "Index::shares_index_with",
18584            "src/lib.rs",
18585            "tests::compares_indexes",
18586        );
18587        assert!(
18588            snapshot.outbound_calls.iter().any(|call| {
18589                call.caller_symbol == "compares_indexes"
18590                    && call.line == 17
18591                    && call.target.starts_with(&format!(
18592                        "shares_index_with{}before.shares_index_with",
18593                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18594                    ))
18595            }),
18596            "expected projected macro receiver call; calls: {:#?}",
18597            snapshot.outbound_calls
18598        );
18599    }
18600
18601    #[test]
18602    fn rust_generic_self_turbofish_method_dispatch_resolves() {
18603        let dir = tempdir().expect("tempdir");
18604        let root = dir.path();
18605        write_rust_manifest(root, "generic-self-fixture");
18606        write_file(
18607            root,
18608            "src/lib.rs",
18609            r#"pub struct Matcher;
18610
18611impl Matcher {
18612    pub fn run(&self) -> bool {
18613        self.fuzzy_match_optimal::<usize>("needle")
18614    }
18615
18616    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
18617        let _ = std::marker::PhantomData::<T>;
18618        true
18619    }
18620
18621    fn planted_dead(&self) {}
18622}
18623
18624pub fn entry() -> bool {
18625    let matcher = Matcher;
18626    matcher.run()
18627}
18628"#,
18629        );
18630
18631        let (store, snapshot) = cold_build_twice(root);
18632        assert_direct_caller(
18633            &store,
18634            "src/lib.rs",
18635            "Matcher::fuzzy_match_optimal",
18636            "src/lib.rs",
18637            "Matcher::run",
18638        );
18639        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
18640        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18641    }
18642
18643    #[test]
18644    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
18645        let dir = tempdir().expect("tempdir");
18646        let root = dir.path();
18647        write_rust_manifest(root, "manifest-operations-fixture");
18648        write_file(
18649            root,
18650            "src/main.rs",
18651            r#"mod dispatch;
18652use dispatch::{manifest_operations};
18653
18654fn main() {
18655    manifest_operations();
18656}
18657"#,
18658        );
18659        write_file(
18660            root,
18661            "src/dispatch.rs",
18662            r#"mod work_graph { fn operations() {} }
18663mod manifest { fn operations() {} }
18664mod audit { fn operations() {} }
18665mod descriptor { fn operations() {} }
18666mod writer { fn operations() {} }
18667
18668pub fn manifest_operations() {
18669    manifest::operations();
18670}
18671
18672pub fn work_graph_operations() {
18673    work_graph::operations();
18674}
18675
18676pub fn audit_operations() {
18677    audit::operations();
18678}
18679
18680pub fn descriptor_operations() {
18681    descriptor::operations();
18682}
18683
18684pub fn writer_operations() {
18685    writer::operations();
18686}
18687
18688fn planted_dead() {}
18689"#,
18690        );
18691
18692        let (store, snapshot) = cold_build_twice(root);
18693        assert_direct_caller(
18694            &store,
18695            "src/dispatch.rs",
18696            "manifest_operations",
18697            "src/main.rs",
18698            "main",
18699        );
18700        assert_direct_caller(
18701            &store,
18702            "src/dispatch.rs",
18703            "manifest::operations",
18704            "src/dispatch.rs",
18705            "manifest_operations",
18706        );
18707        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
18708        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
18709        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
18710    }
18711
18712    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
18713        let files = rust_files(root);
18714        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
18715            .expect("open first store");
18716        first.cold_build(&files).expect("first cold build");
18717        let first_snapshot =
18718            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
18719
18720        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
18721            .expect("open second store");
18722        second.cold_build(&files).expect("second cold build");
18723        let second_snapshot =
18724            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
18725
18726        assert_eq!(
18727            projection_rows(&first_snapshot),
18728            projection_rows(&second_snapshot),
18729            "cold-build projection should be deterministic"
18730        );
18731        (first, first_snapshot)
18732    }
18733
18734    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
18735        let mut rows = Vec::new();
18736        for export in &snapshot.exported_symbols {
18737            rows.push(format!(
18738                "export\t{}\t{}\t{}\t{}",
18739                export.file.display(),
18740                export.symbol,
18741                export.kind,
18742                export.line
18743            ));
18744        }
18745        for call in &snapshot.outbound_calls {
18746            rows.push(format!(
18747                "call\t{}\t{}\t{}\t{}\t{}",
18748                call.caller_file.display(),
18749                call.caller_symbol,
18750                call.target,
18751                call.line,
18752                call.provenance
18753            ));
18754        }
18755        for file in &snapshot.entry_points {
18756            rows.push(format!("entry_file\t{}", file.display()));
18757        }
18758        for (file, symbols) in &snapshot.entry_point_symbols {
18759            for symbol in symbols {
18760                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
18761            }
18762        }
18763        rows.sort();
18764        rows
18765    }
18766
18767    fn assert_direct_caller(
18768        store: &CallGraphStore,
18769        target_rel: &str,
18770        target_symbol: &str,
18771        caller_rel: &str,
18772        caller_symbol: &str,
18773    ) {
18774        let callers = store
18775            .direct_callers_of(Path::new(target_rel), target_symbol)
18776            .unwrap_or_else(|error| {
18777                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
18778            });
18779        assert!(
18780            callers.iter().any(|site| {
18781                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
18782            }),
18783            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
18784        );
18785    }
18786
18787    fn assert_projected_call(
18788        root: &Path,
18789        snapshot: &CallgraphSnapshot,
18790        target_rel: &str,
18791        symbol: &str,
18792    ) {
18793        let target = projected_target(root, target_rel, symbol);
18794        assert!(
18795            snapshot.outbound_calls.iter().any(|call| {
18796                call.target == target
18797                    || call.target.starts_with(&format!(
18798                        "{target}{}",
18799                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18800                    ))
18801            }),
18802            "expected projected call to {target}; calls: {:#?}",
18803            snapshot.outbound_calls
18804        );
18805    }
18806
18807    fn assert_no_projected_call(
18808        root: &Path,
18809        snapshot: &CallgraphSnapshot,
18810        target_rel: &str,
18811        symbol: &str,
18812    ) {
18813        let target = projected_target(root, target_rel, symbol);
18814        assert!(
18815            snapshot.outbound_calls.iter().all(|call| {
18816                call.target != target
18817                    && !call.target.starts_with(&format!(
18818                        "{target}{}",
18819                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18820                    ))
18821            }),
18822            "did not expect projected call to {target}; calls: {:#?}",
18823            snapshot.outbound_calls
18824        );
18825    }
18826
18827    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
18828        // Projection targets carry the normalized (verbatim-stripped)
18829        // canonical form; bare fs::canonicalize diverges on Windows.
18830        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
18831        format!("{}::{symbol}", path.display())
18832    }
18833
18834    fn write_rust_manifest(root: &Path, name: &str) {
18835        write_file(
18836            root,
18837            "Cargo.toml",
18838            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
18839        );
18840    }
18841
18842    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
18843        let path = root.join(rel_path);
18844        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
18845        fs::write(&path, source).expect("write fixture file");
18846        path
18847    }
18848
18849    fn rust_files(root: &Path) -> Vec<PathBuf> {
18850        let mut files = Vec::new();
18851        collect_rust_files(root, &mut files);
18852        files.sort();
18853        files
18854    }
18855
18856    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
18857        for entry in fs::read_dir(dir).expect("read fixture dir") {
18858            let entry = entry.expect("read fixture entry");
18859            let path = entry.path();
18860            if path.is_dir() {
18861                let name = path
18862                    .file_name()
18863                    .and_then(|name| name.to_str())
18864                    .unwrap_or("");
18865                if !name.starts_with(".store") {
18866                    collect_rust_files(&path, files);
18867                }
18868            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
18869                files.push(path);
18870            }
18871        }
18872    }
18873}
18874
18875#[cfg(test)]
18876mod build_pool_tests {
18877    use super::build_pool_size;
18878
18879    #[test]
18880    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
18881        let size = build_pool_size();
18882        // Never zero, never the full core count, never above the 8 cap — this is
18883        // the starvation guard for the cold-build's all-cores tree-sitter pass.
18884        assert!(size >= 1, "pool size must be at least 1");
18885        assert!(size <= 8, "pool size must be capped at 8, got {size}");
18886
18887        let cores = std::thread::available_parallelism()
18888            .map(|p| p.get())
18889            .unwrap_or(1);
18890        let expected = cores.div_ceil(2).clamp(1, 8);
18891        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
18892    }
18893}
18894
18895#[cfg(test)]
18896mod reexport_resolution_tests {
18897    use super::*;
18898
18899    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
18900        ProjectIndex {
18901            facts: Rc::new(DiskFacts::new(Path::new("/fixture"))),
18902            unbound_non_utf8_paths: Vec::new(),
18903            project_root: PathBuf::from("/fixture"),
18904            files: files.into_iter().collect(),
18905            caller_data: HashMap::new(),
18906            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
18907            rust_crate_roots: callgraph::RustCrateRootMemo::default(),
18908        }
18909    }
18910
18911    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
18912        DbFileIndex {
18913            lang: None,
18914            exports: HashSet::new(),
18915            default_export: None,
18916            export_aliases: HashMap::new(),
18917            node_by_scoped: HashMap::new(),
18918            node_by_bare: HashMap::new(),
18919            node_kind_by_id: HashMap::new(),
18920            module_targets: HashMap::new(),
18921            declared_module_targets: HashMap::new(),
18922            reexports: reexport_targets
18923                .iter()
18924                .map(|target| ReexportIndex {
18925                    target_file: Some((*target).to_string()),
18926                    named: HashMap::new(),
18927                    wildcard: true,
18928                })
18929                .collect(),
18930        }
18931    }
18932
18933    /// A dense wildcard re-export cycle (barrel files re-exporting each
18934    /// other) must resolve in O(files), not O(branching^depth). Without the
18935    /// resolver's memoization, resolving a MISSING symbol through this
18936    /// 12-file complete digraph explores ~11^16 paths and this test never
18937    /// finishes: the depth cap bounds path length, not path count, and one
18938    /// such resolution can pin a worker thread at 100% CPU indefinitely.
18939    #[test]
18940    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
18941        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
18942        let files = names
18943            .iter()
18944            .map(|name| {
18945                let targets: Vec<&str> = names
18946                    .iter()
18947                    .filter(|other| *other != name)
18948                    .map(String::as_str)
18949                    .collect();
18950                (name.clone(), barrel_file(&targets))
18951            })
18952            .collect();
18953        let index = barrel_index(files);
18954
18955        assert_eq!(
18956            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
18957            None
18958        );
18959    }
18960
18961    /// Depth-dominance counterexample: the walk first reaches `shared` down a
18962    /// 16-hop chain (no budget left for its leaf), then reaches it again
18963    /// directly at depth 1. Plain visited-set pruning would skip the second
18964    /// visit and lose a resolution the capped resolver finds; the
18965    /// depth-dominance memo revisits because the second arrival is shallower.
18966    #[test]
18967    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
18968        let mut leaf = barrel_file(&[]);
18969        leaf.exports.insert("deep_symbol".to_string());
18970        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
18971        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
18972        // entry's SECOND reexport goes straight to shared.
18973        files.push((
18974            "src/entry.ts".to_string(),
18975            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
18976        ));
18977        for i in 0..15 {
18978            let next = if i == 14 {
18979                "src/shared.ts".to_string()
18980            } else {
18981                format!("src/chain{}.ts", i + 1)
18982            };
18983            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
18984        }
18985        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
18986        files.push(("src/leaf.ts".to_string(), leaf));
18987        let index = barrel_index(files);
18988
18989        assert_eq!(
18990            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
18991            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
18992            "a shallower re-visit must not be pruned by a deeper capped visit"
18993        );
18994    }
18995
18996    #[test]
18997    fn symbol_reachable_through_reexport_cycle_still_resolves() {
18998        let mut leaf = barrel_file(&[]);
18999        leaf.exports.insert("real_symbol".to_string());
19000        let index = barrel_index(vec![
19001            (
19002                "src/a.ts".to_string(),
19003                barrel_file(&["src/b.ts", "src/a.ts"]),
19004            ),
19005            (
19006                "src/b.ts".to_string(),
19007                barrel_file(&["src/a.ts", "src/leaf.ts"]),
19008            ),
19009            ("src/leaf.ts".to_string(), leaf),
19010        ]);
19011
19012        assert_eq!(
19013            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
19014            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
19015        );
19016    }
19017}
19018
19019#[cfg(test)]
19020mod method_dispatch_inference_tests {
19021    use super::*;
19022    use std::fs;
19023    use tempfile::tempdir;
19024
19025    #[test]
19026    fn java_field_receiver_type_selects_declared_class_method() {
19027        let source = r#"class EntryPoint {
19028    private UserService userService;
19029
19030    void handle() {
19031        userService.find();
19032    }
19033}
19034
19035class UserService {
19036    void find() {}
19037}
19038
19039class AuditService {
19040    void find() {}
19041}
19042"#;
19043        let dir = tempdir().expect("temp dir");
19044        let root = dir.path();
19045        write_fixture(root, "src/EntryPoint.java", source);
19046        let reference = reference(
19047            "java",
19048            "src/EntryPoint.java",
19049            "EntryPoint::handle",
19050            "userService",
19051            "find",
19052            line_of(source, "userService.find()"),
19053        );
19054        let mut cache = DispatchSourceCache::new();
19055
19056        let receiver_type =
19057            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
19058        assert_eq!(receiver_type, "UserService");
19059
19060        let candidates = vec![
19061            method_candidate("audit", "AuditService::find"),
19062            method_candidate("user", "UserService::find"),
19063        ];
19064        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
19065            .expect("type candidate");
19066        assert_eq!(selected.scoped_name, "UserService::find");
19067
19068        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
19069        assert!(
19070            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
19071        );
19072    }
19073
19074    #[test]
19075    fn kotlin_property_and_local_value_types_are_inferred() {
19076        let source = r#"class Handler {
19077    private val auditService: AuditService = AuditService()
19078
19079    fun handle() {
19080        auditService.find()
19081        val userService: UserService = UserService()
19082        userService.find()
19083        val billingService = BillingService()
19084        billingService.find()
19085    }
19086}
19087
19088class UserService { fun find() {} }
19089class AuditService { fun find() {} }
19090class BillingService { fun find() {} }
19091"#;
19092        let dir = tempdir().expect("temp dir");
19093        let root = dir.path();
19094        write_fixture(root, "src/Handler.kt", source);
19095        let mut cache = DispatchSourceCache::new();
19096
19097        let audit_ref = reference(
19098            "kotlin",
19099            "src/Handler.kt",
19100            "Handler::handle",
19101            "auditService",
19102            "find",
19103            line_of(source, "auditService.find()"),
19104        );
19105        assert_eq!(
19106            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
19107            Some("AuditService")
19108        );
19109
19110        let user_ref = reference(
19111            "kotlin",
19112            "src/Handler.kt",
19113            "Handler::handle",
19114            "userService",
19115            "find",
19116            line_of(source, "userService.find()"),
19117        );
19118        assert_eq!(
19119            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
19120            Some("UserService")
19121        );
19122
19123        let billing_ref = reference(
19124            "kotlin",
19125            "src/Handler.kt",
19126            "Handler::handle",
19127            "billingService",
19128            "find",
19129            line_of(source, "billingService.find()"),
19130        );
19131        assert_eq!(
19132            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
19133            Some("BillingService")
19134        );
19135    }
19136
19137    #[test]
19138    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
19139        let source = r#"struct Foo { void run(); };
19140struct PointerFoo { void run(); };
19141struct FactoryFoo { void run(); };
19142FactoryFoo makeFactoryFoo();
19143
19144void handle() {
19145    Foo foo;
19146    foo.run();
19147    PointerFoo* pointerFoo = nullptr;
19148    pointerFoo->run();
19149    auto factoryFoo = makeFactoryFoo();
19150    factoryFoo.run();
19151}
19152"#;
19153        let dir = tempdir().expect("temp dir");
19154        let root = dir.path();
19155        write_fixture(root, "src/fixture.cpp", source);
19156        let mut cache = DispatchSourceCache::new();
19157
19158        let foo_ref = reference(
19159            "cpp",
19160            "src/fixture.cpp",
19161            "handle",
19162            "foo",
19163            "run",
19164            line_of(source, "foo.run()"),
19165        );
19166        assert_eq!(
19167            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
19168            Some("Foo")
19169        );
19170
19171        let pointer_ref = reference(
19172            "cpp",
19173            "src/fixture.cpp",
19174            "handle",
19175            "pointerFoo",
19176            "run",
19177            line_of(source, "pointerFoo->run()"),
19178        );
19179        assert_eq!(
19180            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
19181            Some("PointerFoo")
19182        );
19183
19184        let factory_ref = reference(
19185            "cpp",
19186            "src/fixture.cpp",
19187            "handle",
19188            "factoryFoo",
19189            "run",
19190            line_of(source, "factoryFoo.run()"),
19191        );
19192        assert_eq!(
19193            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
19194            Some("FactoryFoo")
19195        );
19196    }
19197
19198    #[test]
19199    fn rust_direct_self_field_name_trims_separator_whitespace() {
19200        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
19201            assert_eq!(
19202                rust_direct_self_field_name(receiver_expression),
19203                Some("engine")
19204            );
19205        }
19206    }
19207
19208    #[test]
19209    fn rust_direct_self_field_receiver_type_is_conservative() {
19210        let source = r#"struct Engine;
19211
19212struct Car {
19213    engine: Engine,
19214}
19215
19216impl Car {
19217    fn run(&self) {
19218        self.engine.start();
19219    }
19220}
19221
19222struct NestedCar {
19223    engine: Engine,
19224}
19225
19226impl NestedCar {
19227    fn run(&self) {
19228        self.inner.engine.start();
19229    }
19230}
19231
19232struct WrappedCar {
19233    engine: Option<Engine>,
19234}
19235
19236impl WrappedCar {
19237    fn run(&self) {
19238        self.engine.start(); // wrapped
19239    }
19240}
19241
19242struct GenericCar<T> {
19243    engine: T,
19244}
19245
19246impl<T> GenericCar<T> {
19247    fn run(&self) {
19248        self.engine.start(); // generic
19249    }
19250}
19251
19252type EngineAlias = Engine;
19253
19254struct AliasCar {
19255    engine: EngineAlias,
19256}
19257
19258impl AliasCar {
19259    fn run(&self) {
19260        self.engine.start(); // alias
19261    }
19262}
19263"#;
19264        let dir = tempdir().expect("temp dir");
19265        let root = dir.path();
19266        write_fixture(root, "src/lib.rs", source);
19267        let mut cache = DispatchSourceCache::new();
19268
19269        let mut direct = reference(
19270            "rust",
19271            "src/lib.rs",
19272            "Car::run",
19273            "engine",
19274            "start",
19275            line_of(source, "self.engine.start()"),
19276        );
19277        direct.receiver_expression = "self.engine".to_string();
19278        assert_eq!(
19279            infer_receiver_type(root, &direct, &mut cache).as_deref(),
19280            Some("Engine")
19281        );
19282
19283        let mut mismatched_impl_target = direct.clone();
19284        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
19285        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
19286
19287        let mut nested = reference(
19288            "rust",
19289            "src/lib.rs",
19290            "NestedCar::run",
19291            "engine",
19292            "start",
19293            line_of(source, "self.inner.engine.start()"),
19294        );
19295        nested.receiver_expression = "self.inner.engine".to_string();
19296        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
19297
19298        let mut wrapped = reference(
19299            "rust",
19300            "src/lib.rs",
19301            "WrappedCar::run",
19302            "engine",
19303            "start",
19304            line_of(source, "self.engine.start(); // wrapped"),
19305        );
19306        wrapped.receiver_expression = "self.engine".to_string();
19307        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
19308
19309        let mut generic = reference(
19310            "rust",
19311            "src/lib.rs",
19312            "GenericCar::run",
19313            "engine",
19314            "start",
19315            line_of(source, "self.engine.start(); // generic"),
19316        );
19317        generic.receiver_expression = "self.engine".to_string();
19318        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
19319
19320        let mut alias = reference(
19321            "rust",
19322            "src/lib.rs",
19323            "AliasCar::run",
19324            "engine",
19325            "start",
19326            line_of(source, "self.engine.start(); // alias"),
19327        );
19328        alias.receiver_expression = "self.engine".to_string();
19329        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
19330    }
19331
19332    #[test]
19333    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
19334        let source = r#"struct Engine;
19335
19336struct Car {
19337    engine: &'static Engine,
19338}
19339
19340impl Car {
19341    fn run(&self) {
19342        self.engine.start();
19343    }
19344}
19345"#;
19346        let dir = tempdir().expect("temp dir");
19347        let root = dir.path();
19348        write_fixture(root, "src/lib.rs", source);
19349        let mut cache = DispatchSourceCache::new();
19350        let mut reference = reference(
19351            "rust",
19352            "src/lib.rs",
19353            "Car::run",
19354            "engine",
19355            "start",
19356            line_of(source, "self.engine.start()"),
19357        );
19358        reference.receiver_expression = "self.engine".to_string();
19359
19360        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19361    }
19362
19363    #[test]
19364    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
19365        let source = r#"trait Drive {
19366    fn run(&self);
19367}
19368
19369struct Engine;
19370
19371struct Car {
19372    engine: Engine,
19373}
19374
19375impl Drive for Car {
19376    fn run(&self) {
19377        self.engine.start();
19378    }
19379}
19380"#;
19381        let dir = tempdir().expect("temp dir");
19382        let root = dir.path();
19383        write_fixture(root, "src/lib.rs", source);
19384        let mut cache = DispatchSourceCache::new();
19385        let mut reference = reference(
19386            "rust",
19387            "src/lib.rs",
19388            "Car::run",
19389            "engine",
19390            "start",
19391            line_of(source, "self.engine.start()"),
19392        );
19393        reference.receiver_expression = "self.engine".to_string();
19394
19395        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19396    }
19397
19398    #[test]
19399    fn rust_self_field_does_not_bind_struct_from_another_module() {
19400        let source = r#"struct Engine;
19401
19402mod unrelated {
19403    struct Car {
19404        engine: Engine,
19405    }
19406}
19407
19408impl Car {
19409    fn run(&self) {
19410        self.engine.start();
19411    }
19412}
19413"#;
19414        let dir = tempdir().expect("temp dir");
19415        let root = dir.path();
19416        write_fixture(root, "src/lib.rs", source);
19417        let mut cache = DispatchSourceCache::new();
19418        let mut reference = reference(
19419            "rust",
19420            "src/lib.rs",
19421            "Car::run",
19422            "engine",
19423            "start",
19424            line_of(source, "self.engine.start()"),
19425        );
19426        reference.receiver_expression = "self.engine".to_string();
19427
19428        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19429    }
19430
19431    #[test]
19432    fn unknown_java_receiver_still_uses_name_match_fallback() {
19433        let source = r#"class EntryPoint {
19434    void handle() {
19435        service.runSpecial();
19436    }
19437}
19438
19439class OnlyService {
19440    void runSpecial() {}
19441}
19442"#;
19443        let dir = tempdir().expect("temp dir");
19444        let root = dir.path();
19445        write_fixture(root, "src/EntryPoint.java", source);
19446        let reference = reference(
19447            "java",
19448            "src/EntryPoint.java",
19449            "EntryPoint::handle",
19450            "service",
19451            "runSpecial",
19452            line_of(source, "service.runSpecial()"),
19453        );
19454        let mut cache = DispatchSourceCache::new();
19455
19456        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19457        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
19458        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
19459        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
19460    }
19461
19462    fn reference(
19463        lang: &str,
19464        caller_file: &str,
19465        caller_symbol: &str,
19466        receiver: &str,
19467        method_name: &str,
19468        line: u32,
19469    ) -> NameMatchRef {
19470        NameMatchRef {
19471            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
19472            caller_node: format!("{caller_symbol}:node"),
19473            caller_file: caller_file.to_string(),
19474            caller_symbol: caller_symbol.to_string(),
19475            caller_signature: None,
19476            receiver_expression: receiver.to_string(),
19477            receiver: receiver.to_string(),
19478            method_name: method_name.to_string(),
19479            colon_dispatch: false,
19480            line,
19481            lang: lang.to_string(),
19482        }
19483    }
19484
19485    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
19486        NameMatchCandidate {
19487            node_id: node_id.to_string(),
19488            file_path: "src/targets.fixture".to_string(),
19489            scoped_name: scoped_name.to_string(),
19490            kind: "method".to_string(),
19491            start_line: 1,
19492        }
19493    }
19494
19495    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
19496        let path = root.join(rel_path);
19497        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
19498        fs::write(path, source).expect("write fixture");
19499    }
19500
19501    fn line_of(source: &str, needle: &str) -> u32 {
19502        source
19503            .lines()
19504            .position(|line| line.contains(needle))
19505            .map(|index| index as u32 + 1)
19506            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
19507    }
19508}
19509
19510#[cfg(test)]
19511mod bounded_build_breaker_tests {
19512    use super::*;
19513    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
19514    use tempfile::tempdir;
19515
19516    #[test]
19517    fn staged_inventory_drives_ordered_bounded_file_batches() {
19518        let temp = tempdir().unwrap();
19519        let root = temp.path().join("root");
19520        std::fs::create_dir_all(&root).unwrap();
19521        let first = root.join("a.ts");
19522        let second = root.join("b.ts");
19523        let third = root.join("c.ts");
19524        for path in [&first, &second, &third] {
19525            std::fs::write(path, "export function item() {}\n").unwrap();
19526        }
19527        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
19528            .unwrap()
19529            .expect("test root may write its private staging database");
19530        let store = CallGraphStore::open_at_path(
19531            root.clone(),
19532            "inventory-key".to_string(),
19533            temp.path().join("inventory.sqlite"),
19534            None,
19535            true,
19536            Some(writer_lease),
19537            None,
19538        )
19539        .unwrap()
19540        .store;
19541        let fingerprint = store
19542            .stage_cold_build_file_inventory(&[
19543                third.clone(),
19544                first.clone(),
19545                second.clone(),
19546                first.clone(),
19547            ])
19548            .unwrap();
19549
19550        let conn = store.conn.lock().unwrap();
19551        assert_eq!(
19552            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
19553            3,
19554            "the primary key deduplicates caller-supplied paths on disk"
19555        );
19556        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
19557            .unwrap()
19558            .expect("first batch");
19559        assert_eq!(first_batch.paths, vec![first.clone(), second]);
19560        let second_batch =
19561            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
19562                .unwrap()
19563                .expect("second batch");
19564        assert_eq!(second_batch.paths, vec![third]);
19565        assert_eq!(
19566            fingerprint,
19567            callgraph_corpus_fingerprint(&root).unwrap(),
19568            "staged and direct streaming fingerprints agree without walk-order dependence"
19569        );
19570    }
19571
19572    #[test]
19573    fn resumed_stage_preserves_committed_batch_and_counter() {
19574        let temp = tempdir().unwrap();
19575        let root = temp.path().join("root");
19576        std::fs::create_dir_all(&root).unwrap();
19577        let first = root.join("first.ts");
19578        let second = root.join("second.ts");
19579        std::fs::write(&first, "export function first() {}\n").unwrap();
19580        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
19581        let staging = temp.path().join("stage.sqlite");
19582        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
19583            .unwrap()
19584            .expect("test root may write its private staging database");
19585        let store = CallGraphStore::open_at_path(
19586            root.clone(),
19587            "test-key".to_string(),
19588            staging,
19589            None,
19590            true,
19591            Some(writer_lease),
19592            None,
19593        )
19594        .unwrap()
19595        .store;
19596        let corpus_fingerprint = store
19597            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
19598            .unwrap();
19599        let first_extract = build_file_extract(&root, &first).unwrap();
19600        let first_bytes = first_extract.freshness.size;
19601        {
19602            let mut conn = store.conn.lock().unwrap();
19603            let tx = conn.transaction().unwrap();
19604            clear_tables(&tx).unwrap();
19605            insert_meta(&tx).unwrap();
19606            drop_cold_build_secondary_indexes(&tx).unwrap();
19607            set_meta_ready(&tx, false).unwrap();
19608            set_staged_build_phase(&tx, "extracting").unwrap();
19609            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
19610            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
19611            {
19612                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
19613                insert_file_extract_prepared(
19614                    &mut inserts,
19615                    &root.display().to_string(),
19616                    &first_extract,
19617                )
19618                .unwrap();
19619                for raw in &first_extract.raw_refs {
19620                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
19621                }
19622            }
19623            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
19624            tx.commit().unwrap();
19625        }
19626
19627        store
19628            .cold_build_chunked(&[first.clone(), second.clone()], 1)
19629            .unwrap();
19630        let conn = store.conn.lock().unwrap();
19631        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
19632        assert_eq!(
19633            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19634            first_bytes + std::fs::metadata(second).unwrap().len(),
19635            "the already committed batch and its credit survive adoption; only the new batch increments credit"
19636        );
19637        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
19638    }
19639
19640    const SPECIMEN_CHILD_TEST: &str =
19641        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
19642    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
19643    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
19644    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
19645    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
19646
19647    fn wait_for_child_barrier(path: &Path) {
19648        let deadline = Instant::now() + Duration::from_secs(10);
19649        while !path.exists() {
19650            assert!(
19651                Instant::now() < deadline,
19652                "callgraph child did not reach barrier {}",
19653                path.display()
19654            );
19655            std::thread::sleep(Duration::from_millis(5));
19656        }
19657    }
19658
19659    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
19660        let signal = store.join("specimen-child.reached");
19661        let _ = std::fs::remove_file(&signal);
19662        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
19663        command
19664            .arg("--exact")
19665            .arg(SPECIMEN_CHILD_TEST)
19666            .arg("--nocapture")
19667            .arg("--test-threads=1")
19668            .env(SPECIMEN_CHILD_ROOT, root)
19669            .env(SPECIMEN_CHILD_STORE, store)
19670            .env(SPECIMEN_CHILD_SIGNAL, &signal)
19671            .stdout(std::process::Stdio::null())
19672            .stderr(std::process::Stdio::null());
19673        if let Some(phase) = phase {
19674            command.env(SPECIMEN_CHILD_PHASE, phase);
19675        }
19676        command.spawn().unwrap()
19677    }
19678
19679    fn staging_path(root: &Path, store: &Path) -> PathBuf {
19680        let project_key = crate::search_index::artifact_cache_key(root);
19681        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
19682    }
19683
19684    fn durable_staging_state(path: &Path) -> (u64, u64) {
19685        if !path.exists() {
19686            return (0, 0);
19687        }
19688        let conn = Connection::open(path).unwrap();
19689        (
19690            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
19691            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19692        )
19693    }
19694
19695    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
19696        wait_for_child_barrier(signal);
19697        child.kill().unwrap();
19698        let _ = child.wait().unwrap();
19699    }
19700
19701    #[test]
19702    fn respawn_loop_build_child() {
19703        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
19704            return;
19705        };
19706        let root = PathBuf::from(root);
19707        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
19708        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
19709            let phase = phase.to_string_lossy().into_owned();
19710            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
19711            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
19712                if observed == phase {
19713                    std::fs::write(&signal, observed.as_bytes()).unwrap();
19714                    std::thread::sleep(Duration::from_secs(30));
19715                }
19716            })));
19717        }
19718        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
19719        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
19720    }
19721
19722    #[test]
19723    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
19724        let temp = tempdir().unwrap();
19725        let root = temp.path().join("resumable-root");
19726        let store = temp.path().join("resumable-store");
19727        std::fs::create_dir_all(&root).unwrap();
19728        std::fs::create_dir_all(&store).unwrap();
19729        for index in 0..3 {
19730            std::fs::write(
19731                root.join(format!("file-{index}.ts")),
19732                format!("export function specimen{index}() {{ return {index}; }}\n"),
19733            )
19734            .unwrap();
19735        }
19736        let stage = staging_path(&root, &store);
19737        let signal = store.join("specimen-child.reached");
19738
19739        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19740        kill_barrier_child(&mut first, &signal);
19741        let (first_rows, first_bytes) = durable_staging_state(&stage);
19742        assert_eq!(first_rows, 1);
19743        assert!(first_bytes > 0);
19744
19745        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19746        kill_barrier_child(&mut second, &signal);
19747        let (second_rows, second_bytes) = durable_staging_state(&stage);
19748        assert_eq!(second_rows, 2);
19749        assert!(
19750            second_bytes > first_bytes,
19751            "a replacement process must adopt committed bytes instead of restarting from zero"
19752        );
19753
19754        let status = spawn_build_child(&root, &store, None).wait().unwrap();
19755        assert!(status.success(), "uninterrupted replacement build failed");
19756        assert!(!stage.exists(), "published staging file must be renamed");
19757        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
19758            .unwrap()
19759            .expect("replacement attempts must converge to a published graph");
19760        assert_eq!(ready.indexed_file_count().unwrap(), 3);
19761
19762        let fast_root = temp.path().join("zero-credit-root");
19763        let fast_store = temp.path().join("zero-credit-store");
19764        std::fs::create_dir_all(&fast_root).unwrap();
19765        std::fs::create_dir_all(&fast_store).unwrap();
19766        std::fs::write(
19767            fast_root.join("main.ts"),
19768            "export function neverCommitted() {}\n",
19769        )
19770        .unwrap();
19771        let fast_stage = staging_path(&fast_root, &fast_store);
19772        let fast_signal = fast_store.join("specimen-child.reached");
19773        let breaker_path = fast_store.join("build-breaker.sqlite");
19774        let now = unix_millis_now();
19775
19776        for death in 0..3 {
19777            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
19778            wait_for_child_barrier(&fast_signal);
19779            let attempt_id = Connection::open(&breaker_path)
19780                .unwrap()
19781                .query_row(
19782                    "SELECT attempt_id FROM breaker_attempts
19783                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
19784                    [],
19785                    |row| row.get::<_, String>(0),
19786                )
19787                .unwrap();
19788            let (_, committed_bytes) = durable_staging_state(&fast_stage);
19789            assert_eq!(
19790                committed_bytes, 0,
19791                "the fast-kill schedule must not cross an extraction commit"
19792            );
19793            child.kill().unwrap();
19794            let _ = child.wait().unwrap();
19795
19796            let key = BreakerKey::new(
19797                fast_root.display().to_string(),
19798                BuildDomain::CallgraphCold,
19799                callgraph_corpus_fingerprint(&fast_root).unwrap(),
19800            );
19801            BuildDeathBreaker::open(&breaker_path)
19802                .unwrap()
19803                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
19804                .unwrap();
19805        }
19806
19807        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
19808        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
19809            .unwrap()
19810            .expect("three zero-credit process deaths must suspend the root");
19811        assert_eq!(suspension.reason, "zero_credit_death_limit");
19812        assert_eq!(suspension.death_count, 3);
19813        let response = crate::commands::callgraph_store_adapter::suspended_response(
19814            "specimen",
19815            "callers",
19816            &suspension,
19817        );
19818        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
19819        let message = response.data["message"].as_str().unwrap();
19820        assert!(
19821            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
19822        );
19823        assert!(message.ends_with(
19824            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
19825        ));
19826        let refused =
19827            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
19828                .expect_err("a suspended root must not report a perpetually building worker");
19829        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
19830    }
19831
19832    #[test]
19833    fn published_callgraph_build_respects_durable_domain_suspension() {
19834        let temp = tempdir().unwrap();
19835        let root = temp.path().join("root");
19836        let store_dir = temp.path().join("store");
19837        std::fs::create_dir_all(&root).unwrap();
19838        let source = root.join("main.ts");
19839        std::fs::write(&source, "export function marker() {}\n").unwrap();
19840        let files = vec![source];
19841        let key = BreakerKey::new(
19842            root.display().to_string(),
19843            BuildDomain::CallgraphCold,
19844            callgraph_corpus_fingerprint(&root).unwrap(),
19845        );
19846        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
19847        for _ in 0..3 {
19848            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
19849                panic!("unexpected early suspension");
19850            };
19851            breaker
19852                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
19853                .unwrap();
19854        }
19855
19856        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
19857            .expect_err("durably tripped callgraph domain must refuse a new cold build");
19858        assert!(matches!(
19859            error,
19860            CallGraphStoreError::Suspended(ref suspension)
19861                if suspension.domain == BuildDomain::CallgraphCold
19862                    && suspension.death_count == 3
19863        ));
19864    }
19865}