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;
780#[cfg(test)]
781pub(crate) use dead_code_projection::{
782    project_dead_code_snapshot_incremental, set_projection_before_open_observer,
783    take_projection_work,
784};
785pub(crate) use dead_code_projection::{
786    project_dead_code_snapshot_incremental_with_costs, project_dead_code_snapshot_with_revision,
787    ProjectionCostEstimates, ProjectionKind, ProjectionVerdict, MAX_DELTA_BYTES,
788};
789
790#[doc(hidden)]
791pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
792    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
793}
794
795#[cfg(test)]
796fn set_cold_build_before_publish_observer(observer: Option<Arc<ColdBuildBeforePublishObserver>>) {
797    COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
798}
799
800#[cfg(test)]
801fn notify_cold_build_before_publish_observer() {
802    let observer = COLD_BUILD_BEFORE_PUBLISH_OBSERVER.with(|slot| slot.borrow().clone());
803    if let Some(observer) = observer {
804        observer();
805    }
806}
807
808#[cfg(not(test))]
809fn notify_cold_build_before_publish_observer() {}
810
811#[cfg(test)]
812fn set_cold_build_slice_observer(observer: Option<Arc<ColdBuildSliceObserver>>) {
813    COLD_BUILD_SLICE_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
814}
815
816#[cfg(test)]
817fn notify_cold_build_slice_observer(stage: &'static str, completed: usize, total: usize) {
818    let observer = COLD_BUILD_SLICE_OBSERVER.with(|slot| slot.borrow().clone());
819    if let Some(observer) = observer {
820        observer(stage, completed, total);
821    }
822}
823
824#[cfg(not(test))]
825fn notify_cold_build_slice_observer(_stage: &'static str, _completed: usize, _total: usize) {}
826
827#[cfg(test)]
828fn set_cold_build_extract_observer(observer: Option<Arc<ColdBuildExtractObserver>>) {
829    COLD_BUILD_EXTRACT_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
830}
831
832#[cfg(test)]
833fn notify_cold_build_extract_observer(paths: &[PathBuf]) {
834    let observer = COLD_BUILD_EXTRACT_OBSERVER.with(|slot| slot.borrow().clone());
835    if let Some(observer) = observer {
836        observer(paths);
837    }
838}
839
840#[cfg(not(test))]
841fn notify_cold_build_extract_observer(_paths: &[PathBuf]) {}
842
843#[doc(hidden)]
844pub fn set_legacy_migration_available_disk_for_test(bytes: Option<u64>) {
845    MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow_mut() = bytes);
846}
847
848#[doc(hidden)]
849pub fn set_legacy_migration_fail_after_temp_copy_for_test(enabled: bool) {
850    MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.set(enabled));
851}
852
853#[doc(hidden)]
854pub fn set_legacy_migration_backup_budget_exhausted_for_test(enabled: bool) {
855    MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.set(enabled));
856}
857
858struct PublishAdmissionGuard {
859    previous: Option<(crate::root_cache::ArtifactPublishEpoch, u64)>,
860}
861
862impl Drop for PublishAdmissionGuard {
863    fn drop(&mut self) {
864        PUBLISH_ADMISSION.with(|slot| {
865            *slot.borrow_mut() = self.previous.take();
866        });
867    }
868}
869
870pub(crate) fn with_publish_epoch<R>(
871    epoch: crate::root_cache::ArtifactPublishEpoch,
872    expected: u64,
873    run: impl FnOnce() -> R,
874) -> R {
875    let previous = PUBLISH_ADMISSION.with(|slot| slot.replace(Some((epoch, expected))));
876    let _guard = PublishAdmissionGuard { previous };
877    run()
878}
879
880fn ensure_cold_build_current(stage: &'static str, completed: usize, total: usize) -> Result<()> {
881    notify_cold_build_slice_observer(stage, completed, total);
882    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
883    if admission.is_none_or(|(epoch, expected)| epoch.is_current(expected)) {
884        if let Some(scope) = crate::logging::current_index_build() {
885            crate::logging::log_index_event(
886                crate::logging::IndexEvent::from_scope(
887                    crate::logging::IndexEventKind::BuildProgress,
888                    &scope,
889                )
890                .field("stage", stage)
891                .field("completed", completed)
892                .field("total", total)
893                .field("elapsed_ms", scope.elapsed_ms()),
894            );
895        }
896        return Ok(());
897    }
898    crate::slog_info!(
899        "callgraph cold build superseded, stopping after {}/{} ({})",
900        completed,
901        total,
902        stage
903    );
904    if let Some(scope) = crate::logging::current_index_build() {
905        crate::logging::log_index_event(
906            crate::logging::IndexEvent::from_scope(
907                crate::logging::IndexEventKind::BuildSuperseded,
908                &scope,
909            )
910            .field("stage", stage)
911            .field("completed", completed)
912            .field("total", total),
913        );
914    }
915    Err(CallGraphStoreError::Superseded)
916}
917
918fn publish_if_current<R>(publish: impl FnOnce() -> Result<R>) -> Result<R> {
919    let admission = PUBLISH_ADMISSION.with(|slot| slot.borrow().clone());
920    match admission {
921        Some((epoch, expected)) => epoch
922            .run_if_current(expected, publish)
923            .unwrap_or(Err(CallGraphStoreError::Superseded)),
924        None => publish(),
925    }
926}
927
928struct RefreshCommitAdmissionGuard {
929    previous: Option<(
930        SubcLifecycleAdmission,
931        Arc<std::sync::atomic::AtomicU64>,
932        u64,
933    )>,
934}
935
936impl Drop for RefreshCommitAdmissionGuard {
937    fn drop(&mut self) {
938        REFRESH_COMMIT_ADMISSION.with(|slot| {
939            *slot.borrow_mut() = self.previous.take();
940        });
941    }
942}
943
944fn with_refresh_commit_admission<R>(
945    lifecycle: SubcLifecycleAdmission,
946    generation_flag: Arc<std::sync::atomic::AtomicU64>,
947    expected_generation: u64,
948    run: impl FnOnce() -> R,
949) -> R {
950    let previous = REFRESH_COMMIT_ADMISSION
951        .with(|slot| slot.replace(Some((lifecycle, generation_flag, expected_generation))));
952    let _guard = RefreshCommitAdmissionGuard { previous };
953    run()
954}
955
956fn commit_incremental_if_current(tx: Transaction<'_>) -> Result<()> {
957    let admission = REFRESH_COMMIT_ADMISSION.with(|slot| slot.borrow().clone());
958    let commit = || {
959        publish_if_current(|| {
960            tx.commit()?;
961            Ok(())
962        })
963    };
964    match admission {
965        Some((lifecycle, generation_flag, expected_generation)) => lifecycle
966            .run_if_current(generation_flag.as_ref(), expected_generation, commit)
967            .unwrap_or(Err(CallGraphStoreError::Superseded)),
968        None => commit(),
969    }
970}
971
972fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
973    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
974    if let Some(observer) = observer {
975        observer(temp_path, target_path);
976    }
977}
978
979#[derive(Debug)]
980pub enum CallGraphStoreError {
981    Io(std::io::Error),
982    Sqlite(rusqlite::Error),
983    Json(serde_json::Error),
984    Aft(AftError),
985    Lock(crate::fs_lock::AcquireError),
986    MissingCallerData {
987        file: String,
988    },
989    Unavailable(String),
990    PathIdentityMismatch {
991        path: PathBuf,
992        project_root: PathBuf,
993    },
994    Suspended(crate::build_breaker::BuildSuspension),
995    Superseded,
996    StaleFiles(Vec<String>),
997}
998
999impl CallGraphStoreError {
1000    pub(crate) fn is_transient_lock_contention(&self) -> bool {
1001        matches!(
1002            self,
1003            Self::Sqlite(rusqlite::Error::SqliteFailure(error, _))
1004                if matches!(
1005                    error.code,
1006                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
1007                )
1008        )
1009    }
1010}
1011
1012impl fmt::Display for CallGraphStoreError {
1013    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1014        match self {
1015            Self::Io(error) => write!(formatter, "I/O error: {error}"),
1016            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
1017            Self::Json(error) => write!(formatter, "json error: {error}"),
1018            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
1019            Self::Lock(error) => write!(formatter, "callgraph writer lease error: {error}"),
1020            Self::MissingCallerData { file } => {
1021                write!(formatter, "missing extracted caller data for {file}")
1022            }
1023            Self::Unavailable(message) => {
1024                write!(formatter, "callgraph store unavailable: {message}")
1025            }
1026            Self::PathIdentityMismatch { path, project_root } => write!(
1027                formatter,
1028                "callgraph path identity mismatch: {} is not under project root {}",
1029                path.display(),
1030                project_root.display()
1031            ),
1032            Self::Suspended(suspension) => write!(
1033                formatter,
1034                "callgraph build suspended for {} after {} deaths ({})",
1035                suspension.domain.as_str(),
1036                suspension.death_count,
1037                suspension.reason
1038            ),
1039            Self::Superseded => {
1040                write!(formatter, "callgraph store build superseded before publish")
1041            }
1042            Self::StaleFiles(files) => {
1043                write!(
1044                    formatter,
1045                    "callgraph store has stale files: {}",
1046                    files.join(", ")
1047                )
1048            }
1049        }
1050    }
1051}
1052
1053impl std::error::Error for CallGraphStoreError {}
1054
1055impl From<std::io::Error> for CallGraphStoreError {
1056    fn from(error: std::io::Error) -> Self {
1057        Self::Io(error)
1058    }
1059}
1060
1061impl From<rusqlite::Error> for CallGraphStoreError {
1062    fn from(error: rusqlite::Error) -> Self {
1063        Self::Sqlite(error)
1064    }
1065}
1066
1067impl From<serde_json::Error> for CallGraphStoreError {
1068    fn from(error: serde_json::Error) -> Self {
1069        Self::Json(error)
1070    }
1071}
1072
1073impl From<AftError> for CallGraphStoreError {
1074    fn from(error: AftError) -> Self {
1075        Self::Aft(error)
1076    }
1077}
1078
1079impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
1080    fn from(error: crate::fs_lock::AcquireError) -> Self {
1081        Self::Lock(error)
1082    }
1083}
1084
1085pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
1086
1087/// Config flag name gating whether the store is opened (default on). Production
1088/// commands open it through `open_if_enabled` so the substrate can be disabled
1089/// without code changes.
1090pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
1091
1092#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1093pub struct CallGraphStoreOptions {
1094    pub enabled: bool,
1095}
1096
1097pub type PendingCallGraphStorePaths = Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>;
1098
1099/// Shared context state that lets the refresh worker observe a store installed
1100/// after its batch was opened. The worker clones the installed store Arc before
1101/// checking it, so no context lock guard crosses the check or enqueue call.
1102#[derive(Clone)]
1103pub(crate) struct CallgraphRefreshState {
1104    store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1105    heavy_root_work_allowed: Arc<AtomicBool>,
1106}
1107
1108impl CallgraphRefreshState {
1109    pub(crate) fn new(
1110        store: Arc<std::sync::RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1111        heavy_root_work_allowed: Arc<AtomicBool>,
1112    ) -> Self {
1113        Self {
1114            store,
1115            heavy_root_work_allowed,
1116        }
1117    }
1118
1119    fn installed_store_snapshot(&self) -> Option<Arc<ReadonlyCallGraphStore>> {
1120        self.store
1121            .read()
1122            .unwrap_or_else(std::sync::PoisonError::into_inner)
1123            .as_ref()
1124            .map(Arc::clone)
1125    }
1126}
1127
1128type WorkspaceCratePrefixes = HashMap<String, String>;
1129
1130#[derive(Clone, Debug, Default)]
1131struct WorkspaceCratePrefixCache(Arc<OnceLock<WorkspaceCratePrefixes>>);
1132
1133const REFRESH_WORKSPACE_CACHE_ROOT_CAP: usize = 128;
1134
1135pub(crate) fn invalidates_workspace_crate_prefix_cache(path: &Path) -> bool {
1136    path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")
1137}
1138
1139/// A JS workspace manifest change can add or remove members, so the
1140/// process-wide workspace package cache must be dropped before the refresh
1141/// resolves imports against it. The cache is otherwise cleared only on
1142/// configure, which is what makes it worth having across refreshes.
1143pub(crate) fn invalidates_workspace_package_cache(path: &Path) -> bool {
1144    matches!(
1145        path.file_name().and_then(|name| name.to_str()),
1146        Some("package.json") | Some("pnpm-workspace.yaml")
1147    )
1148}
1149
1150#[derive(Clone, Debug, Hash, PartialEq, Eq)]
1151struct RefreshRoot {
1152    callgraph_dir: PathBuf,
1153    project_root: PathBuf,
1154}
1155
1156#[derive(Clone)]
1157pub(crate) struct CallgraphRefreshTicket {
1158    lifecycle: SubcLifecycleAdmission,
1159    generation_flag: Arc<std::sync::atomic::AtomicU64>,
1160    expected_generation: u64,
1161    publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1162    expected_publish_epoch: u64,
1163}
1164
1165impl CallgraphRefreshTicket {
1166    pub(crate) fn new(
1167        lifecycle: SubcLifecycleAdmission,
1168        generation_flag: Arc<std::sync::atomic::AtomicU64>,
1169        expected_generation: u64,
1170        publish_epoch: crate::root_cache::ArtifactPublishEpoch,
1171        expected_publish_epoch: u64,
1172    ) -> Self {
1173        Self {
1174            lifecycle,
1175            generation_flag,
1176            expected_generation,
1177            publish_epoch,
1178            expected_publish_epoch,
1179        }
1180    }
1181
1182    fn is_current(&self) -> bool {
1183        self.lifecycle
1184            .is_current(self.generation_flag.as_ref(), self.expected_generation)
1185            && self.publish_epoch.current() == self.expected_publish_epoch
1186    }
1187}
1188
1189#[derive(Clone)]
1190struct RefreshBatch {
1191    root: RefreshRoot,
1192    paths: BTreeSet<PathBuf>,
1193    pending_sinks: Vec<PendingCallGraphStorePaths>,
1194    refresh_states: Vec<CallgraphRefreshState>,
1195    ticket: Option<CallgraphRefreshTicket>,
1196}
1197
1198impl RefreshBatch {
1199    fn defer(&self) {
1200        for sink in &self.pending_sinks {
1201            sink.lock().extend(self.paths.iter().cloned());
1202        }
1203    }
1204
1205    fn defer_after_open_failure(&self) {
1206        self.defer();
1207        if self
1208            .ticket
1209            .as_ref()
1210            .is_some_and(|ticket| !ticket.is_current())
1211            || !self
1212                .refresh_states
1213                .iter()
1214                .any(|state| state.heavy_root_work_allowed.load(AtomicOrdering::SeqCst))
1215        {
1216            return;
1217        }
1218
1219        let ready_store_installed = self.refresh_states.iter().any(|state| {
1220            let store = state.installed_store_snapshot();
1221            store.is_some_and(|store| {
1222                store.project_root() == self.root.project_root
1223                    && !store.is_legacy_fallback()
1224                    && store.is_current()
1225            })
1226        });
1227        if !ready_store_installed {
1228            return;
1229        }
1230
1231        // This re-check and the ready-store install's pending-sink take form a
1232        // check-then-act handoff: after this defer, exactly one site observes
1233        // the parked paths with a ready current store, so no polling is needed.
1234        for sink in &self.pending_sinks {
1235            let paths = {
1236                let mut pending = sink.lock();
1237                self.paths
1238                    .iter()
1239                    .filter(|path| pending.remove(*path))
1240                    .cloned()
1241                    .collect::<Vec<_>>()
1242            };
1243            if paths.is_empty() {
1244                continue;
1245            }
1246            let _ = enqueue_callgraph_store_refresh_inner(
1247                self.root.callgraph_dir.clone(),
1248                self.root.project_root.clone(),
1249                paths,
1250                Arc::clone(sink),
1251                self.refresh_states.clone(),
1252                self.ticket.clone(),
1253            );
1254        }
1255    }
1256
1257    fn merge(
1258        &mut self,
1259        paths: impl IntoIterator<Item = PathBuf>,
1260        sink: PendingCallGraphStorePaths,
1261        refresh_states: Vec<CallgraphRefreshState>,
1262        ticket: Option<CallgraphRefreshTicket>,
1263    ) {
1264        self.paths.extend(paths);
1265        if ticket.is_some() {
1266            self.ticket = ticket;
1267        }
1268        if !self
1269            .pending_sinks
1270            .iter()
1271            .any(|existing| Arc::ptr_eq(existing, &sink))
1272        {
1273            self.pending_sinks.push(sink);
1274        }
1275        for refresh_state in refresh_states {
1276            if !self.refresh_states.iter().any(|existing| {
1277                Arc::ptr_eq(&existing.store, &refresh_state.store)
1278                    && Arc::ptr_eq(
1279                        &existing.heavy_root_work_allowed,
1280                        &refresh_state.heavy_root_work_allowed,
1281                    )
1282            }) {
1283                self.refresh_states.push(refresh_state);
1284            }
1285        }
1286    }
1287}
1288
1289#[derive(Default)]
1290struct RefreshQueue {
1291    order: VecDeque<RefreshRoot>,
1292    queued: HashMap<RefreshRoot, RefreshBatch>,
1293    active: Option<RefreshBatch>,
1294    shutdown_requested: bool,
1295}
1296
1297struct RefreshWorkerShared {
1298    queue: Mutex<RefreshQueue>,
1299    wake: Condvar,
1300}
1301
1302struct RefreshWorker {
1303    shared: Arc<RefreshWorkerShared>,
1304    thread: Mutex<Option<JoinHandle<()>>>,
1305}
1306
1307struct RefreshWorkerWatchdog {
1308    first_path: PathBuf,
1309    batch_len: usize,
1310    started: Instant,
1311}
1312
1313impl RefreshWorkerWatchdog {
1314    fn start(paths: &[PathBuf]) -> Self {
1315        Self {
1316            first_path: paths
1317                .first()
1318                .expect("non-empty callgraph refresh batch has a first path")
1319                .clone(),
1320            batch_len: paths.len(),
1321            started: Instant::now(),
1322        }
1323    }
1324}
1325
1326impl Drop for RefreshWorkerWatchdog {
1327    fn drop(&mut self) {
1328        let elapsed = self.started.elapsed();
1329        if elapsed < REFRESH_WORKER_WARN_AFTER {
1330            return;
1331        }
1332        let path = if self.batch_len == 1 {
1333            self.first_path.display().to_string()
1334        } else {
1335            format!(
1336                "{} (+{} paths)",
1337                self.first_path.display(),
1338                self.batch_len - 1
1339            )
1340        };
1341        log::warn!(
1342            "watcher drain unit exceeded 5s: phase=callgraph path={} elapsed={}ms",
1343            path,
1344            elapsed.as_millis()
1345        );
1346        if elapsed >= REFRESH_WORKER_FINAL_AFTER {
1347            log::warn!(
1348                "watcher drain unit completed after 30s: phase=callgraph path={} elapsed={}ms",
1349                path,
1350                elapsed.as_millis()
1351            );
1352        }
1353    }
1354}
1355
1356impl RefreshWorker {
1357    fn spawn() -> Arc<Self> {
1358        let shared = Arc::new(RefreshWorkerShared {
1359            queue: Mutex::new(RefreshQueue::default()),
1360            wake: Condvar::new(),
1361        });
1362        let thread_shared = Arc::clone(&shared);
1363        let thread = std::thread::Builder::new()
1364            .name("aft-callgraph-refresh".to_string())
1365            .spawn(move || callgraph_refresh_worker_loop(&thread_shared))
1366            .expect("failed to spawn callgraph refresh worker");
1367        Arc::new(Self {
1368            shared,
1369            thread: Mutex::new(Some(thread)),
1370        })
1371    }
1372
1373    fn enqueue(
1374        &self,
1375        root: RefreshRoot,
1376        paths: Vec<PathBuf>,
1377        pending_sink: PendingCallGraphStorePaths,
1378        refresh_states: Vec<CallgraphRefreshState>,
1379        ticket: Option<CallgraphRefreshTicket>,
1380    ) -> bool {
1381        let mut queue = self
1382            .shared
1383            .queue
1384            .lock()
1385            .expect("callgraph refresh queue mutex poisoned");
1386        if queue.shutdown_requested {
1387            pending_sink.lock().extend(paths);
1388            return false;
1389        }
1390        if let Some(batch) = queue.queued.get_mut(&root) {
1391            batch.merge(paths, pending_sink, refresh_states, ticket);
1392        } else {
1393            queue.order.push_back(root.clone());
1394            queue.queued.insert(
1395                root.clone(),
1396                RefreshBatch {
1397                    root,
1398                    paths: paths.into_iter().collect(),
1399                    pending_sinks: vec![pending_sink],
1400                    refresh_states,
1401                    ticket,
1402                },
1403            );
1404        }
1405        self.shared.wake.notify_one();
1406        true
1407    }
1408
1409    fn shutdown_with_budget(&self, budget: Duration) -> bool {
1410        let deadline = Instant::now() + budget;
1411        let mut queue = self
1412            .shared
1413            .queue
1414            .lock()
1415            .expect("callgraph refresh queue mutex poisoned");
1416        queue.shutdown_requested = true;
1417        self.shared.wake.notify_one();
1418        while (queue.active.is_some() || !queue.order.is_empty()) && Instant::now() < deadline {
1419            let remaining = deadline.saturating_duration_since(Instant::now());
1420            let (next, _) = self
1421                .shared
1422                .wake
1423                .wait_timeout(queue, remaining)
1424                .expect("callgraph refresh queue mutex poisoned while waiting for shutdown");
1425            queue = next;
1426        }
1427        let drained = queue.active.is_none() && queue.order.is_empty();
1428        if !drained {
1429            if let Some(active) = queue.active.as_ref() {
1430                active.defer();
1431            }
1432            for batch in queue.queued.values() {
1433                batch.defer();
1434            }
1435            queue.order.clear();
1436            queue.queued.clear();
1437        }
1438        drop(queue);
1439
1440        if drained {
1441            if let Some(thread) = self
1442                .thread
1443                .lock()
1444                .expect("callgraph refresh worker thread mutex poisoned")
1445                .take()
1446            {
1447                let _ = thread.join();
1448            }
1449        }
1450        drained
1451    }
1452}
1453
1454static CALLGRAPH_REFRESH_WORKER: OnceLock<Mutex<Option<Arc<RefreshWorker>>>> = OnceLock::new();
1455
1456pub fn enqueue_callgraph_store_refresh(
1457    callgraph_dir: PathBuf,
1458    project_root: PathBuf,
1459    paths: Vec<PathBuf>,
1460    pending_sink: PendingCallGraphStorePaths,
1461) -> bool {
1462    enqueue_callgraph_store_refresh_inner(
1463        callgraph_dir,
1464        project_root,
1465        paths,
1466        pending_sink,
1467        Vec::new(),
1468        None,
1469    )
1470}
1471
1472#[cfg(test)]
1473pub(crate) fn enqueue_callgraph_store_refresh_fenced(
1474    callgraph_dir: PathBuf,
1475    project_root: PathBuf,
1476    paths: Vec<PathBuf>,
1477    pending_sink: PendingCallGraphStorePaths,
1478    ticket: CallgraphRefreshTicket,
1479) -> bool {
1480    enqueue_callgraph_store_refresh_inner(
1481        callgraph_dir,
1482        project_root,
1483        paths,
1484        pending_sink,
1485        Vec::new(),
1486        Some(ticket),
1487    )
1488}
1489
1490pub(crate) fn enqueue_callgraph_store_refresh_fenced_with_state(
1491    callgraph_dir: PathBuf,
1492    project_root: PathBuf,
1493    paths: Vec<PathBuf>,
1494    pending_sink: PendingCallGraphStorePaths,
1495    refresh_state: CallgraphRefreshState,
1496    ticket: CallgraphRefreshTicket,
1497) -> bool {
1498    enqueue_callgraph_store_refresh_inner(
1499        callgraph_dir,
1500        project_root,
1501        paths,
1502        pending_sink,
1503        vec![refresh_state],
1504        Some(ticket),
1505    )
1506}
1507
1508fn enqueue_callgraph_store_refresh_inner(
1509    callgraph_dir: PathBuf,
1510    project_root: PathBuf,
1511    paths: Vec<PathBuf>,
1512    pending_sink: PendingCallGraphStorePaths,
1513    refresh_states: Vec<CallgraphRefreshState>,
1514    ticket: Option<CallgraphRefreshTicket>,
1515) -> bool {
1516    if paths.is_empty() {
1517        return true;
1518    }
1519    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1520    let worker = {
1521        let mut worker = slot
1522            .lock()
1523            .expect("callgraph refresh worker mutex poisoned");
1524        Arc::clone(worker.get_or_insert_with(RefreshWorker::spawn))
1525    };
1526    worker.enqueue(
1527        RefreshRoot {
1528            callgraph_dir,
1529            project_root,
1530        },
1531        paths,
1532        pending_sink,
1533        refresh_states,
1534        ticket,
1535    )
1536}
1537
1538pub fn flush_callgraph_store_refreshes_on_graceful_shutdown() -> bool {
1539    flush_callgraph_store_refreshes_with_budget(REFRESH_WORKER_GRACEFUL_SHUTDOWN_BUDGET)
1540}
1541
1542#[doc(hidden)]
1543pub fn flush_callgraph_store_refreshes_with_budget(budget: Duration) -> bool {
1544    let slot = CALLGRAPH_REFRESH_WORKER.get_or_init(|| Mutex::new(None));
1545    let worker = slot
1546        .lock()
1547        .expect("callgraph refresh worker mutex poisoned")
1548        .clone();
1549    let Some(worker) = worker else {
1550        return true;
1551    };
1552    let drained = worker.shutdown_with_budget(budget);
1553    if drained {
1554        let mut current = slot
1555            .lock()
1556            .expect("callgraph refresh worker mutex poisoned");
1557        if current
1558            .as_ref()
1559            .is_some_and(|candidate| Arc::ptr_eq(candidate, &worker))
1560        {
1561            *current = None;
1562        }
1563    }
1564    drained
1565}
1566
1567fn idle_checkpoint_due(last: Option<Instant>, now: Instant) -> bool {
1568    last.is_none_or(|last| now.saturating_duration_since(last) >= REFRESH_IDLE_CHECKPOINT_INTERVAL)
1569}
1570
1571fn callgraph_refresh_worker_loop(shared: &RefreshWorkerShared) {
1572    // The worker owns these caches so maps are shared only by refreshes for the
1573    // same canonical root and disappear when the worker shuts down.
1574    let mut workspace_crate_prefixes = HashMap::new();
1575    let mut last_idle_checkpoints: HashMap<RefreshRoot, Instant> = HashMap::new();
1576    loop {
1577        let batch = {
1578            let mut queue = shared
1579                .queue
1580                .lock()
1581                .expect("callgraph refresh queue mutex poisoned");
1582            loop {
1583                if let Some(root) = queue.order.pop_front() {
1584                    let batch = queue
1585                        .queued
1586                        .remove(&root)
1587                        .expect("queued callgraph refresh root has a batch");
1588                    queue.active = Some(batch.clone());
1589                    break batch;
1590                }
1591                if queue.shutdown_requested {
1592                    return;
1593                }
1594                queue = shared
1595                    .wake
1596                    .wait(queue)
1597                    .expect("callgraph refresh queue mutex poisoned while waiting");
1598            }
1599        };
1600
1601        let store = process_callgraph_refresh_batch(&batch, &mut workspace_crate_prefixes);
1602
1603        let mut queue = shared
1604            .queue
1605            .lock()
1606            .expect("callgraph refresh queue mutex poisoned");
1607        queue.active = None;
1608        let became_idle = queue.order.is_empty();
1609        shared.wake.notify_all();
1610        drop(queue);
1611
1612        if became_idle {
1613            let checkpoint_due = idle_checkpoint_due(
1614                last_idle_checkpoints.get(&batch.root).copied(),
1615                Instant::now(),
1616            );
1617            if checkpoint_due {
1618                if let Some(store) = store {
1619                    if store.checkpoint_wal_truncate() {
1620                        last_idle_checkpoints.insert(batch.root.clone(), Instant::now());
1621                    }
1622                }
1623            }
1624        }
1625    }
1626}
1627
1628fn process_callgraph_refresh_batch(
1629    batch: &RefreshBatch,
1630    workspace_crate_prefixes: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1631) -> Option<CallGraphStore> {
1632    // A manifest event is an invalidation signal, not a source file to parse.
1633    // Drop the root's map even for a superseded batch: the filesystem changed,
1634    // and a later configure must never inherit crate membership from before it.
1635    if batch
1636        .paths
1637        .iter()
1638        .any(|path| invalidates_workspace_crate_prefix_cache(path))
1639    {
1640        workspace_crate_prefixes.remove(&batch.root);
1641    }
1642    if batch
1643        .paths
1644        .iter()
1645        .any(|path| invalidates_workspace_package_cache(path))
1646    {
1647        callgraph::clear_workspace_package_cache();
1648    }
1649
1650    let paths = batch
1651        .paths
1652        .iter()
1653        .filter(|path| crate::parser::detect_language(path).is_some())
1654        .cloned()
1655        .collect::<Vec<_>>();
1656    if paths.is_empty() {
1657        return None;
1658    }
1659    note_refresh_worker_batch_for_test(&batch.root.project_root, &paths);
1660    if batch
1661        .ticket
1662        .as_ref()
1663        .is_some_and(|ticket| !ticket.is_current())
1664    {
1665        // Superseded before starting: park the paths so the next configure's
1666        // pending replay (or unbind cleanup) decides their fate.
1667        batch.defer();
1668        return None;
1669    }
1670    let workspace_crate_prefix_cache =
1671        workspace_crate_prefix_cache_for_root(workspace_crate_prefixes, &batch.root);
1672    let _watchdog = RefreshWorkerWatchdog::start(&paths);
1673    let test_seam = refresh_worker_test_seam(&batch.root.project_root);
1674    note_refresh_worker_call_for_test(&batch.root.project_root);
1675    let opened = if test_seam.fail_open {
1676        Ok(None)
1677    } else {
1678        CallGraphStore::open_ready(
1679            batch.root.callgraph_dir.clone(),
1680            batch.root.project_root.clone(),
1681        )
1682    };
1683    if let Some(gate) = take_refresh_worker_test_gate(&batch.root.project_root) {
1684        // The gate is deliberately after open_ready so tests can hold a failed
1685        // open between its result and the defer that parks the batch.
1686        let _ = gate.held_tx.send(());
1687        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1688    }
1689    let store = match opened {
1690        Ok(Some(store)) => store,
1691        Ok(None) => {
1692            batch.defer_after_open_failure();
1693            return None;
1694        }
1695        Err(error) => {
1696            batch.defer_after_open_failure();
1697            crate::slog_warn!(
1698                "callgraph store writer open failed during refresh; deferred paths: {}",
1699                error
1700            );
1701            return None;
1702        }
1703    };
1704    if !test_seam.delay.is_zero() {
1705        std::thread::sleep(test_seam.delay);
1706    }
1707    if batch
1708        .ticket
1709        .as_ref()
1710        .is_some_and(|ticket| !ticket.is_current())
1711    {
1712        // This is a superseded-ticket defer, not an open-failure defer: leave
1713        // the paths for the replacement configure instead of self-replaying.
1714        batch.defer();
1715        return Some(store);
1716    }
1717    let refresh_result = if test_seam.fail_refresh {
1718        Err(CallGraphStoreError::Unavailable(
1719            "injected refresh worker failure".to_string(),
1720        ))
1721    } else if let Some(ticket) = &batch.ticket {
1722        with_publish_epoch(
1723            ticket.publish_epoch.clone(),
1724            ticket.expected_publish_epoch,
1725            || {
1726                with_refresh_commit_admission(
1727                    ticket.lifecycle.clone(),
1728                    Arc::clone(&ticket.generation_flag),
1729                    ticket.expected_generation,
1730                    || {
1731                        store
1732                            .refresh_files_with_workspace_crate_prefix_cache(
1733                                &paths,
1734                                workspace_crate_prefix_cache.clone(),
1735                            )
1736                            .map(|_| ())
1737                    },
1738                )
1739            },
1740        )
1741    } else {
1742        store
1743            .refresh_files_with_workspace_crate_prefix_cache(
1744                &paths,
1745                workspace_crate_prefix_cache.clone(),
1746            )
1747            .map(|_| ())
1748    };
1749    if matches!(refresh_result, Err(CallGraphStoreError::Superseded)) {
1750        // The commit lost the fence race: a newer configure or publication
1751        // owns the store now. Defer instead of stale-marking — the paths were
1752        // never committed, and the replacement generation re-indexes them.
1753        batch.defer();
1754        return Some(store);
1755    }
1756    if let Err(error) = refresh_result {
1757        crate::slog_warn!("callgraph store refresh failed: {}", error);
1758        match store.mark_files_stale(&paths) {
1759            Ok(marked) => {
1760                note_refresh_worker_stale_mark_for_test(&batch.root.project_root);
1761                crate::slog_warn!(
1762                    "marked {} callgraph store file(s) stale after refresh failure",
1763                    marked.len()
1764                );
1765            }
1766            Err(mark_error) => crate::slog_warn!(
1767                "failed to mark callgraph store files stale after refresh failure: {}",
1768                mark_error
1769            ),
1770        }
1771    } else {
1772        crate::logging::note_callgraph_invalidations(paths.len());
1773    }
1774    Some(store)
1775}
1776
1777fn workspace_crate_prefix_cache_for_root(
1778    caches: &mut HashMap<RefreshRoot, WorkspaceCratePrefixCache>,
1779    root: &RefreshRoot,
1780) -> WorkspaceCratePrefixCache {
1781    if !caches.contains_key(root) && caches.len() >= REFRESH_WORKSPACE_CACHE_ROOT_CAP {
1782        // Eviction only costs a future rebuild; it cannot make resolution stale.
1783        if let Some(evicted) = caches.keys().next().cloned() {
1784            caches.remove(&evicted);
1785        }
1786    }
1787    caches.entry(root.clone()).or_default().clone()
1788}
1789
1790#[derive(Clone, Default)]
1791struct RefreshWorkerTestSeam {
1792    delay: Duration,
1793    fail_refresh: bool,
1794    fail_open: bool,
1795    refresh_calls: usize,
1796    worker_calls: usize,
1797    stale_marks: usize,
1798    received_paths: BTreeSet<PathBuf>,
1799}
1800
1801static REFRESH_WORKER_TEST_SEAMS: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestSeam>>> =
1802    OnceLock::new();
1803
1804struct RefreshWorkerTestGate {
1805    held_tx: crossbeam_channel::Sender<()>,
1806    release_rx: crossbeam_channel::Receiver<()>,
1807}
1808
1809static REFRESH_WORKER_TEST_GATES: OnceLock<Mutex<HashMap<PathBuf, RefreshWorkerTestGate>>> =
1810    OnceLock::new();
1811
1812#[doc(hidden)]
1813pub fn install_callgraph_refresh_worker_test_gate(
1814    project_root: PathBuf,
1815) -> (
1816    crossbeam_channel::Receiver<()>,
1817    crossbeam_channel::Sender<()>,
1818) {
1819    let (held_tx, held_rx) = crossbeam_channel::bounded(1);
1820    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1821    REFRESH_WORKER_TEST_GATES
1822        .get_or_init(|| Mutex::new(HashMap::new()))
1823        .lock()
1824        .expect("callgraph refresh test gate mutex poisoned")
1825        .insert(
1826            project_root,
1827            RefreshWorkerTestGate {
1828                held_tx,
1829                release_rx,
1830            },
1831        );
1832    (held_rx, release_tx)
1833}
1834
1835fn take_refresh_worker_test_gate(project_root: &Path) -> Option<RefreshWorkerTestGate> {
1836    REFRESH_WORKER_TEST_GATES
1837        .get_or_init(|| Mutex::new(HashMap::new()))
1838        .lock()
1839        .expect("callgraph refresh test gate mutex poisoned")
1840        .remove(project_root)
1841}
1842
1843fn refresh_worker_test_seam(project_root: &Path) -> RefreshWorkerTestSeam {
1844    let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() else {
1845        return RefreshWorkerTestSeam::default();
1846    };
1847    seams
1848        .lock()
1849        .expect("callgraph refresh test seam mutex poisoned")
1850        .get(project_root)
1851        .cloned()
1852        .unwrap_or_default()
1853}
1854
1855fn note_refresh_worker_batch_for_test(project_root: &Path, paths: &[PathBuf]) {
1856    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1857        if let Some(seam) = seams
1858            .lock()
1859            .expect("callgraph refresh test seam mutex poisoned")
1860            .get_mut(project_root)
1861        {
1862            seam.worker_calls += 1;
1863            seam.received_paths.extend(paths.iter().cloned());
1864        }
1865    }
1866}
1867
1868fn note_refresh_worker_call_for_test(project_root: &Path) {
1869    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1870        if let Some(seam) = seams
1871            .lock()
1872            .expect("callgraph refresh test seam mutex poisoned")
1873            .get_mut(project_root)
1874        {
1875            seam.refresh_calls += 1;
1876        }
1877    }
1878}
1879
1880fn note_refresh_worker_stale_mark_for_test(project_root: &Path) {
1881    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1882        if let Some(seam) = seams
1883            .lock()
1884            .expect("callgraph refresh test seam mutex poisoned")
1885            .get_mut(project_root)
1886        {
1887            seam.stale_marks += 1;
1888        }
1889    }
1890}
1891
1892#[doc(hidden)]
1893pub fn set_callgraph_refresh_worker_test_seam(
1894    project_root: PathBuf,
1895    delay: Duration,
1896    fail_refresh: bool,
1897) {
1898    REFRESH_WORKER_TEST_SEAMS
1899        .get_or_init(|| Mutex::new(HashMap::new()))
1900        .lock()
1901        .expect("callgraph refresh test seam mutex poisoned")
1902        .insert(
1903            project_root,
1904            RefreshWorkerTestSeam {
1905                delay,
1906                fail_refresh,
1907                ..RefreshWorkerTestSeam::default()
1908            },
1909        );
1910}
1911
1912#[doc(hidden)]
1913pub fn set_callgraph_refresh_worker_test_open_failure(project_root: PathBuf, enabled: bool) {
1914    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1915        if let Some(seam) = seams
1916            .lock()
1917            .expect("callgraph refresh test seam mutex poisoned")
1918            .get_mut(&project_root)
1919        {
1920            seam.fail_open = enabled;
1921        }
1922    }
1923}
1924
1925#[doc(hidden)]
1926pub fn callgraph_refresh_worker_test_counts(project_root: &Path) -> (usize, usize) {
1927    let seam = refresh_worker_test_seam(project_root);
1928    (seam.refresh_calls, seam.stale_marks)
1929}
1930
1931#[doc(hidden)]
1932pub fn callgraph_refresh_worker_test_worker_calls(project_root: &Path) -> usize {
1933    refresh_worker_test_seam(project_root).worker_calls
1934}
1935
1936#[doc(hidden)]
1937pub fn callgraph_refresh_worker_test_paths(project_root: &Path) -> BTreeSet<PathBuf> {
1938    refresh_worker_test_seam(project_root).received_paths
1939}
1940
1941#[doc(hidden)]
1942pub fn clear_callgraph_refresh_worker_test_seam(project_root: &Path) {
1943    if let Some(seams) = REFRESH_WORKER_TEST_SEAMS.get() {
1944        seams
1945            .lock()
1946            .expect("callgraph refresh test seam mutex poisoned")
1947            .remove(project_root);
1948    }
1949}
1950
1951#[derive(Debug)]
1952pub struct CallGraphStore {
1953    project_root: PathBuf,
1954    project_key: String,
1955    /// The concrete on-disk DB file this store opened. With the generation
1956    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
1957    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
1958    sqlite_path: PathBuf,
1959    /// Root-keyed directory whose pointer controls this store. For a legacy
1960    /// fallback this intentionally differs from `sqlite_path.parent()`, so a
1961    /// newly published root-keyed generation invalidates the fallback reader.
1962    publication_dir: PathBuf,
1963    /// True only when the root-keyed read path opened data from a legacy
1964    /// harness partition. Writer-capable callers use this to schedule migration
1965    /// without making read-only/worktree callers acquire a writer lease.
1966    legacy_fallback: bool,
1967    manifest_view: bool,
1968    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
1969    /// or `None` when it opened the legacy single-file DB. Used to detect when
1970    /// another process has published a newer generation so this process can
1971    /// drop its connection and reopen (see `current_generation`).
1972    generation: Option<String>,
1973    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
1974    read_marker: Option<crate::root_cache::ReadMarker>,
1975    // Readiness is monotonic for an open generation: builds only publish `ready=1`.
1976    // Failed validations are not cached, so a later successful build remains visible.
1977    database_ready: AtomicBool,
1978    write_metrics: Arc<CallgraphWriteMetrics>,
1979    conn: Mutex<TrackedConnection>,
1980}
1981
1982#[derive(Debug)]
1983pub struct ReadonlyCallGraphStore {
1984    inner: CallGraphStore,
1985    _view_pin: Option<Arc<crate::pins::QueryPin>>,
1986}
1987
1988pub trait CallGraphRead {
1989    fn project_root(&self) -> &Path;
1990    fn project_key(&self) -> &str;
1991    fn sqlite_path(&self) -> &Path;
1992    fn is_current(&self) -> bool;
1993    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>>;
1994    fn indexed_file_count(&self) -> Result<usize>;
1995    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode>;
1996    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>>;
1997    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>>;
1998    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>>;
1999    fn direct_callers_for_symbols(
2000        &self,
2001        targets: &[(String, String)],
2002    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
2003        targets
2004            .iter()
2005            .cloned()
2006            .map(|target| {
2007                let callers = self.direct_callers_of(Path::new(&target.0), &target.1)?;
2008                Ok((target, callers))
2009            })
2010            .collect()
2011    }
2012    fn direct_caller_counts_of(
2013        &self,
2014        targets: &[(String, String)],
2015    ) -> Result<HashMap<(String, String), usize>>;
2016    fn outgoing_calls_for_symbols(
2017        &self,
2018        sources: &[(String, String)],
2019    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>>;
2020    fn callers_of(&self, file_rel: &Path, symbol: &str, depth: usize)
2021        -> Result<StoreCallersResult>;
2022    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult>;
2023    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2024    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>>;
2025    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>>;
2026    fn call_tree(
2027        &self,
2028        file_rel: &Path,
2029        symbol: &str,
2030        depth: usize,
2031    ) -> Result<callgraph::CallTreeNode>;
2032    fn trace_to(
2033        &self,
2034        file_rel: &Path,
2035        symbol: &str,
2036        max_depth: usize,
2037    ) -> Result<callgraph::TraceToResult>;
2038    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>>;
2039    fn trace_to_symbol(
2040        &self,
2041        file_rel: &Path,
2042        symbol: &str,
2043        to_symbol: &str,
2044        to_file: Option<&Path>,
2045        max_depth: usize,
2046    ) -> Result<callgraph::TraceToSymbolResult>;
2047}
2048
2049#[derive(Debug, Clone, PartialEq, Eq)]
2050enum OpenRootRepair {
2051    None,
2052    ReRooted,
2053    NeedsRebuild {
2054        previous_roots: Vec<String>,
2055        current_root: String,
2056        reason: String,
2057    },
2058}
2059
2060struct OpenedStore {
2061    store: CallGraphStore,
2062    root_repair: OpenRootRepair,
2063}
2064
2065#[derive(Clone, Debug)]
2066struct LegacyCallgraphPartition {
2067    harness: String,
2068    dir: PathBuf,
2069    key: String,
2070    bytes: u64,
2071    freshness: Option<SystemTime>,
2072}
2073
2074#[derive(Clone, Debug)]
2075struct LegacyCallgraphTarget {
2076    partition: LegacyCallgraphPartition,
2077    sqlite_path: PathBuf,
2078    generation: Option<String>,
2079    source_bytes: u64,
2080    source_blake3: String,
2081}
2082
2083#[derive(Clone, Debug)]
2084struct SourceFingerprint {
2085    bytes: u64,
2086    blake3: String,
2087}
2088
2089#[derive(Clone, Debug)]
2090struct PublishedLegacyMigration {
2091    generation: String,
2092    migrated_bytes: u64,
2093}
2094
2095#[derive(Debug, Clone)]
2096pub struct ColdBuildStats {
2097    pub files: usize,
2098    pub nodes: usize,
2099    pub refs: usize,
2100    pub edges: usize,
2101    pub failed_files: Vec<String>,
2102    pub elapsed_ms: u128,
2103}
2104
2105#[derive(Debug, Clone)]
2106pub struct IncrementalStats {
2107    pub changed_files: Vec<String>,
2108    pub surface_changed: Vec<String>,
2109    pub deleted_files: Vec<String>,
2110    pub dependency_selected_refs: usize,
2111    pub refreshed_own_files: usize,
2112    pub unchanged_extract_files: usize,
2113}
2114
2115/// Phase timings for the copy-based incremental refresh benchmark.
2116#[doc(hidden)]
2117#[derive(Debug, Clone, Default, PartialEq, Eq)]
2118pub struct RefreshFilesProfile {
2119    pub parse: Duration,
2120    pub dependency_selection: Duration,
2121    pub row_deletes: Duration,
2122    pub row_inserts: Duration,
2123    pub dependent_parse: Duration,
2124    pub index_load: Duration,
2125    pub index_loads: usize,
2126    pub ref_resolution: Duration,
2127    pub method_dispatch: Duration,
2128    pub commit: Duration,
2129    pub total: Duration,
2130}
2131
2132impl RefreshFilesProfile {
2133    pub fn report(&self) -> String {
2134        format!(
2135            "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",
2136            self.parse.as_millis(),
2137            self.dependency_selection.as_millis(),
2138            self.row_deletes.as_millis(),
2139            self.row_inserts.as_millis(),
2140            self.dependent_parse.as_millis(),
2141            self.index_load.as_millis(),
2142            self.ref_resolution.as_millis(),
2143            self.method_dispatch.as_millis(),
2144            self.commit.as_millis(),
2145            self.total.as_millis(),
2146        )
2147    }
2148}
2149
2150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2151pub struct StoredEdge {
2152    pub source_file: String,
2153    pub source_symbol: String,
2154    pub target_file: String,
2155    pub target_symbol: String,
2156    pub kind: String,
2157    pub line: u32,
2158}
2159
2160#[derive(Debug, Clone, PartialEq, Eq)]
2161pub struct StoreNode {
2162    node_id: String,
2163    pub file: String,
2164    pub symbol: String,
2165    pub name: String,
2166    pub kind: String,
2167    pub line: u32,
2168    pub end_line: u32,
2169    pub signature: Option<String>,
2170    pub exported: bool,
2171    pub is_entry_point: bool,
2172    pub lang: LangId,
2173}
2174
2175#[cfg(test)]
2176impl StoreNode {
2177    pub(crate) fn for_test(file: &str, symbol: &str, is_entry_point: bool) -> Self {
2178        Self {
2179            node_id: format!("{file}:{symbol}"),
2180            file: file.to_string(),
2181            symbol: symbol.to_string(),
2182            name: symbol.to_string(),
2183            kind: "function".to_string(),
2184            line: 1,
2185            end_line: 1,
2186            signature: None,
2187            exported: is_entry_point,
2188            is_entry_point,
2189            lang: LangId::TypeScript,
2190        }
2191    }
2192}
2193
2194#[derive(Debug, Clone, PartialEq, Eq)]
2195pub struct StoreCallSite {
2196    pub caller: StoreNode,
2197    pub target_file: String,
2198    pub target_symbol: String,
2199    pub target: Option<StoreNode>,
2200    pub line: u32,
2201    pub byte_start: usize,
2202    pub byte_end: usize,
2203    pub resolved: bool,
2204    pub provenance: String,
2205}
2206
2207impl StoreCallSite {
2208    pub fn approximate(&self) -> bool {
2209        self.provenance == PROVENANCE_NAME_MATCH
2210    }
2211
2212    pub fn resolved_by(&self) -> &str {
2213        &self.provenance
2214    }
2215
2216    pub fn supplemental_resolution(&self) -> Option<&str> {
2217        match self.provenance.as_str() {
2218            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
2219            _ => None,
2220        }
2221    }
2222}
2223
2224#[derive(Debug, Clone, PartialEq, Eq)]
2225pub struct StoreUnresolvedCall {
2226    pub caller: StoreNode,
2227    pub symbol: String,
2228    pub full_ref: Option<String>,
2229    pub line: u32,
2230    pub byte_start: usize,
2231    pub byte_end: usize,
2232}
2233
2234#[derive(Debug, Clone, PartialEq, Eq)]
2235pub struct StoreCallersResult {
2236    pub target: StoreNode,
2237    pub callers: Vec<StoreCallSite>,
2238    pub scanned_files: usize,
2239    pub depth_limited: bool,
2240    pub truncated: usize,
2241}
2242
2243#[derive(Debug, Clone, PartialEq, Eq)]
2244pub struct StoreImpactCaller {
2245    pub site: StoreCallSite,
2246    pub signature: Option<String>,
2247    pub is_entry_point: bool,
2248    pub call_expression: Option<String>,
2249    pub parameters: Vec<String>,
2250}
2251
2252#[derive(Debug, Clone, PartialEq, Eq)]
2253pub struct StoreImpactResult {
2254    pub target: StoreNode,
2255    pub parameters: Vec<String>,
2256    pub callers: Vec<StoreImpactCaller>,
2257    pub depth_limited: bool,
2258    pub truncated: usize,
2259}
2260
2261#[derive(Debug, Clone)]
2262struct ExtractFailure {
2263    rel_path: String,
2264    freshness: Option<FileFreshness>,
2265}
2266
2267#[derive(Debug, Clone)]
2268struct BuildExtractsResult {
2269    extracts: Vec<FileExtract>,
2270    failures: Vec<ExtractFailure>,
2271}
2272
2273#[derive(Debug, Clone)]
2274enum StoreForwardCall {
2275    Resolved(StoreCallSite),
2276    Unresolved(StoreUnresolvedCall),
2277}
2278
2279impl StoreForwardCall {
2280    fn byte_start(&self) -> usize {
2281        match self {
2282            Self::Resolved(site) => site.byte_start,
2283            Self::Unresolved(call) => call.byte_start,
2284        }
2285    }
2286
2287    fn line(&self) -> u32 {
2288        match self {
2289            Self::Resolved(site) => site.line,
2290            Self::Unresolved(call) => call.line,
2291        }
2292    }
2293}
2294
2295#[derive(Debug, Clone)]
2296struct FileExtract {
2297    rel_path: String,
2298    freshness: FileFreshness,
2299    lang: LangId,
2300    data: FileCallData,
2301    nodes: Vec<NodeRecord>,
2302    raw_refs: Vec<RawRef>,
2303    dispatch_hints: Vec<DispatchHint>,
2304    surface_fingerprint: String,
2305}
2306
2307#[derive(Debug, Clone)]
2308struct NodeRecord {
2309    id: String,
2310    file_path: String,
2311    name: String,
2312    scoped_name: String,
2313    kind: String,
2314    range: Range,
2315    range_ordinal: u32,
2316    signature: Option<String>,
2317    exported: bool,
2318    is_default_export: bool,
2319    is_type_like: bool,
2320    is_callgraph_entry_point: bool,
2321}
2322
2323#[derive(Debug, Clone)]
2324struct RawRef {
2325    ref_id: String,
2326    caller_node: Option<String>,
2327    caller_symbol: Option<String>,
2328    caller_file: String,
2329    kind: String,
2330    short_name: Option<String>,
2331    full_ref: Option<String>,
2332    module_path: Option<String>,
2333    import_kind: Option<String>,
2334    local_name: Option<String>,
2335    requested_name: Option<String>,
2336    namespace_alias: Option<String>,
2337    wildcard: bool,
2338    line: u32,
2339    byte_start: usize,
2340    byte_end: usize,
2341    dependencies: BTreeSet<String>,
2342}
2343
2344/// A raw reference read from the durable staging table with its SQLite ordering
2345/// key. The ordering key is advanced only in the same transaction that writes
2346/// the resolved result, so a crash resumes at a committed window boundary.
2347#[derive(Debug)]
2348struct StagedRef {
2349    rowid: u64,
2350    raw: RawRef,
2351}
2352
2353#[derive(Debug, Clone)]
2354struct ResolvedRef {
2355    raw: RawRef,
2356    status: String,
2357    target_node: Option<String>,
2358    target_file: Option<String>,
2359    target_symbol: Option<String>,
2360    dependencies: BTreeSet<String>,
2361    edge: Option<EdgeRecord>,
2362}
2363
2364#[derive(Debug, Clone)]
2365struct EdgeRecord {
2366    edge_id: String,
2367    source_node: String,
2368    target_node: Option<String>,
2369    target_file: String,
2370    target_symbol: String,
2371    kind: String,
2372    line: u32,
2373}
2374
2375#[derive(Debug, Clone)]
2376struct DispatchHint {
2377    id: String,
2378    method_name: String,
2379    caller_node: String,
2380    file: String,
2381    line: u32,
2382    byte_start: usize,
2383    byte_end: usize,
2384}
2385
2386#[derive(Debug, Clone)]
2387struct NameMatchRef {
2388    ref_id: String,
2389    caller_node: String,
2390    caller_file: String,
2391    caller_symbol: String,
2392    caller_signature: Option<String>,
2393    receiver_expression: String,
2394    receiver: String,
2395    method_name: String,
2396    colon_dispatch: bool,
2397    line: u32,
2398    lang: String,
2399}
2400
2401#[derive(Debug, Clone)]
2402struct NameMatchCandidate {
2403    node_id: String,
2404    file_path: String,
2405    scoped_name: String,
2406    kind: String,
2407    // Nodes persist tree-sitter's zero-based rows; dispatch AST helpers use one-based lines.
2408    start_line: u32,
2409}
2410
2411#[derive(Debug, Clone)]
2412struct FileRow {
2413    surface_fingerprint: String,
2414    freshness: FileFreshness,
2415}
2416
2417#[derive(Debug, Clone)]
2418struct DbFileIndex {
2419    lang: Option<LangId>,
2420    exports: HashSet<String>,
2421    default_export: Option<String>,
2422    export_aliases: HashMap<String, String>,
2423    node_by_scoped: HashMap<String, String>,
2424    node_by_bare: HashMap<String, String>,
2425    node_kind_by_id: HashMap<String, String>,
2426    module_targets: HashMap<String, Option<String>>,
2427    declared_module_targets: HashMap<String, Option<String>>,
2428    reexports: Vec<ReexportIndex>,
2429}
2430
2431#[derive(Debug, Clone)]
2432struct ReexportIndex {
2433    target_file: Option<String>,
2434    named: HashMap<String, String>,
2435    wildcard: bool,
2436}
2437
2438#[derive(Clone)]
2439struct ProjectIndex<'a> {
2440    facts: Rc<dyn ProjectFacts + 'a>,
2441    unbound_non_utf8_paths: Vec<Vec<u8>>,
2442    project_root: PathBuf,
2443    files: HashMap<String, DbFileIndex>,
2444    caller_data: HashMap<String, &'a FileCallData>,
2445    /// Root-scoped map shared by successive refresh-worker batches. Cargo.toml
2446    /// watcher events replace the cache before another batch can resolve refs.
2447    /// Cold/direct refreshes use a private cache so each refresh builds and uses
2448    /// its own workspace mapping.
2449    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2450    rust_crate_roots: callgraph::RustCrateRootMemo,
2451}
2452
2453/// Resolution reads symbols and exports through one interface. Incremental
2454/// refreshes use the in-memory index, while cold builds query only the rows
2455/// needed by the active caller from SQLite.
2456trait ResolverIndex {
2457    fn caller_data(&self, file: &str) -> Option<&FileCallData>;
2458    fn lang_for(&self, file: &str) -> Option<LangId>;
2459    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String>;
2460    fn module_parent(&self, target_file: &str) -> Option<(String, String)>;
2461    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex>;
2462    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String>;
2463    fn node_is_callable(&self, file: &str, node_id: &str) -> bool;
2464    fn export_alias(&self, file: &str, symbol: &str) -> Option<String>;
2465    fn has_export(&self, file: &str, symbol: &str) -> bool;
2466    fn default_export(&self, file: &str) -> Option<String>;
2467    fn contains_file(&self, file: &str) -> bool;
2468    fn crate_src_prefix(&self, crate_name: &str) -> Option<String>;
2469    fn rust_crate_root_file(&self, caller_file: &str) -> Option<String>;
2470    fn inline_scoped_target(
2471        &self,
2472        caller_file: &str,
2473        module_segments: &[String],
2474        short_name: &str,
2475    ) -> Option<(String, String)>;
2476}
2477
2478impl ResolverIndex for ProjectIndex<'_> {
2479    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2480        self.caller_data.get(file).copied()
2481    }
2482
2483    fn lang_for(&self, file: &str) -> Option<LangId> {
2484        self.lang_for(file)
2485    }
2486
2487    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2488        self.module_target(caller_file, module_path)
2489    }
2490
2491    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2492        let mut parents = self
2493            .files
2494            .iter()
2495            .flat_map(|(file, index)| {
2496                index
2497                    .declared_module_targets
2498                    .iter()
2499                    .filter_map(move |(module, target)| {
2500                        (target.as_deref() == Some(target_file))
2501                            .then(|| (file.clone(), module.clone()))
2502                    })
2503            })
2504            .collect::<Vec<_>>();
2505        parents.sort();
2506        parents.into_iter().next()
2507    }
2508
2509    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2510        self.reexports_for(file).to_vec()
2511    }
2512
2513    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2514        self.node_for_symbol(file, symbol)
2515    }
2516
2517    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2518        self.node_is_callable(file, node_id)
2519    }
2520
2521    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2522        self.files
2523            .get(file)
2524            .and_then(|item| item.export_aliases.get(symbol))
2525            .cloned()
2526    }
2527
2528    fn has_export(&self, file: &str, symbol: &str) -> bool {
2529        self.files
2530            .get(file)
2531            .is_some_and(|item| item.exports.contains(symbol))
2532    }
2533
2534    fn default_export(&self, file: &str) -> Option<String> {
2535        self.files
2536            .get(file)
2537            .and_then(|item| item.default_export.clone())
2538    }
2539
2540    fn contains_file(&self, file: &str) -> bool {
2541        self.files.contains_key(file)
2542    }
2543
2544    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2545        if self.workspace_crate_prefixes.0.get().is_some() {
2546            self.facts.memo_replay(&self.project_root, "crates", "");
2547        }
2548        self.workspace_crate_prefixes
2549            .0
2550            .get_or_init(|| {
2551                self.facts.memo_start(&self.project_root, "crates", "");
2552                let prefixes = build_workspace_crate_prefixes(
2553                    &self.project_root,
2554                    &FactPaths {
2555                        root: &self.project_root,
2556                        facts: self.facts.as_ref(),
2557                    },
2558                );
2559                self.facts.memo_finish(&self.project_root, "crates", "");
2560                prefixes
2561            })
2562            .get(crate_name)
2563            .cloned()
2564    }
2565
2566    fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2567        let paths = FactPaths {
2568            root: &self.project_root,
2569            facts: self.facts.as_ref(),
2570        };
2571        callgraph::rust_crate_root_file_for_caller(
2572            &self.project_root,
2573            &self.project_root.join(caller_file),
2574            &paths,
2575            &self.rust_crate_roots,
2576        )
2577        .map(|path| relative_path(&self.project_root, &path))
2578    }
2579
2580    fn inline_scoped_target(
2581        &self,
2582        caller_file: &str,
2583        module_segments: &[String],
2584        short_name: &str,
2585    ) -> Option<(String, String)> {
2586        let src_prefix = rust_src_prefix(caller_file);
2587        let mut file_paths = self.files.keys().cloned().collect::<Vec<_>>();
2588        file_paths.sort();
2589        if let Some(position) = file_paths.iter().position(|file| file == caller_file) {
2590            let caller = file_paths.remove(position);
2591            file_paths.insert(0, caller);
2592        }
2593        for file_path in file_paths {
2594            if self.lang_for(&file_path) != Some(LangId::Rust)
2595                || rust_src_prefix(&file_path) != src_prefix
2596            {
2597                continue;
2598            }
2599            let file_module_segments = rust_module_segments_for_rel(&file_path);
2600            if !module_segments.starts_with(&file_module_segments) {
2601                continue;
2602            }
2603            let scoped_segments = &module_segments[file_module_segments.len()..];
2604            if scoped_segments.is_empty() {
2605                continue;
2606            }
2607            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2608            if self.node_for_symbol(&file_path, &scoped_symbol).is_some() {
2609                return Some((file_path, scoped_symbol));
2610            }
2611        }
2612        None
2613    }
2614}
2615
2616/// A cold-build resolver view that loads file indexes on demand. The bounded
2617/// memo keeps repeated lookups cheap without making the heap proportional to an
2618/// unbounded project corpus.
2619///
2620/// Instances are created only after extraction commits every `files`, `nodes`,
2621/// `file_dependencies`, and structural `refs` input and the indexing fence moves
2622/// staging to `resolving`. Resolution replaces `refs` rows only to add status,
2623/// target, and provenance outputs, and inserts `edges`; `DbFileIndex` reads none
2624/// of those output columns. Its inputs therefore stay immutable for an instance,
2625/// so the memo needs no generation key or slice-fence invalidation.
2626struct DiskProjectIndex<'a> {
2627    project_root: &'a Path,
2628    conn: &'a Connection,
2629    caller_file: &'a str,
2630    caller_data: &'a FileCallData,
2631    workspace_crate_prefixes: WorkspaceCratePrefixCache,
2632    module_resolution_memo: &'a callgraph::ModuleResolutionMemo,
2633    file_index_memo: RefCell<HashMap<String, Option<Rc<DbFileIndex>>>>,
2634    module_parent_memo: RefCell<HashMap<String, Option<(String, String)>>>,
2635    memoize_resolver_indexes: bool,
2636}
2637
2638impl DiskProjectIndex<'_> {
2639    fn file_index(&self, rel_path: &str) -> Option<Rc<DbFileIndex>> {
2640        if self.memoize_resolver_indexes {
2641            if let Some(cached) = self.file_index_memo.borrow().get(rel_path).cloned() {
2642                return cached;
2643            }
2644        }
2645
2646        let loaded = self.load_file_index(rel_path).map(Rc::new);
2647        if self.memoize_resolver_indexes {
2648            let mut memo = self.file_index_memo.borrow_mut();
2649            if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2650                // Keep the active caller's index hot even when an unusually broad
2651                // resolution walk exhausts the bounded target-file memo.
2652                let caller_index = memo.remove(self.caller_file);
2653                memo.clear();
2654                if let Some(caller_index) = caller_index {
2655                    memo.insert(self.caller_file.to_string(), caller_index);
2656                }
2657            }
2658            memo.insert(rel_path.to_string(), loaded.clone());
2659        }
2660        loaded
2661    }
2662
2663    fn load_file_index(&self, rel_path: &str) -> Option<DbFileIndex> {
2664        let lang: String = self
2665            .conn
2666            .query_row(
2667                "SELECT lang FROM files WHERE path = ?1",
2668                params![rel_path],
2669                |row| row.get(0),
2670            )
2671            .optional()
2672            .ok()??;
2673        let mut index = DbFileIndex {
2674            lang: lang_from_label(&lang),
2675            exports: HashSet::new(),
2676            default_export: None,
2677            export_aliases: HashMap::new(),
2678            node_by_scoped: HashMap::new(),
2679            node_by_bare: HashMap::new(),
2680            node_kind_by_id: HashMap::new(),
2681            module_targets: HashMap::new(),
2682            declared_module_targets: HashMap::new(),
2683            reexports: Vec::new(),
2684        };
2685        let mut nodes = self
2686            .conn
2687            .prepare(
2688                "SELECT id, name, scoped_name, kind, exported, is_default_export
2689                 FROM nodes WHERE file_path = ?1",
2690            )
2691            .ok()?;
2692        let rows = nodes
2693            .query_map(params![rel_path], |row| {
2694                Ok((
2695                    row.get::<_, String>(0)?,
2696                    row.get::<_, String>(1)?,
2697                    row.get::<_, String>(2)?,
2698                    row.get::<_, String>(3)?,
2699                    row.get::<_, i64>(4)? != 0,
2700                    row.get::<_, i64>(5)? != 0,
2701                ))
2702            })
2703            .ok()?
2704            .collect::<std::result::Result<Vec<_>, _>>()
2705            .ok()?;
2706        drop(nodes);
2707        for (id, name, scoped_name, kind, exported, is_default_export) in rows {
2708            if exported {
2709                index.exports.insert(name.clone());
2710                index.exports.insert(scoped_name.clone());
2711            }
2712            if is_default_export {
2713                index.default_export = Some(scoped_name.clone());
2714            }
2715            index.node_by_scoped.insert(scoped_name, id.clone());
2716            index.node_by_bare.entry(name).or_insert(id.clone());
2717            index.node_kind_by_id.insert(id, kind);
2718        }
2719
2720        let mut refs = self
2721            .conn
2722            .prepare(
2723                "SELECT ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name
2724                  FROM refs
2725                  WHERE caller_file = ?1 AND kind IN ('import', 'module', 'reexport', 'export_alias')",
2726            )
2727            .ok()?;
2728        let rows = refs
2729            .query_map(params![rel_path], |row| {
2730                Ok((
2731                    row.get::<_, String>(0)?,
2732                    row.get::<_, String>(1)?,
2733                    row.get::<_, Option<String>>(2)?,
2734                    row.get::<_, Option<String>>(3)?,
2735                    row.get::<_, i64>(4)? != 0,
2736                    row.get::<_, Option<String>>(5)?,
2737                    row.get::<_, Option<String>>(6)?,
2738                ))
2739            })
2740            .ok()?
2741            .collect::<std::result::Result<Vec<_>, _>>()
2742            .ok()?;
2743        drop(refs);
2744        for (ref_id, kind, module_path, full_ref, wildcard, local_name, requested_name) in rows {
2745            if kind == "export_alias" {
2746                if let (Some(exported), Some(source)) = (local_name, requested_name) {
2747                    index.export_aliases.insert(exported, source);
2748                }
2749                continue;
2750            }
2751            let Some(module_path) = module_path else {
2752                continue;
2753            };
2754            let target_file = if kind == "module" {
2755                rust_declared_module_target(
2756                    self.project_root,
2757                    rel_path,
2758                    &module_path,
2759                    self.module_resolution_memo,
2760                    &FactPaths {
2761                        root: self.project_root,
2762                        facts: &DiskFacts::new(self.project_root),
2763                    },
2764                )
2765            } else {
2766                self.disk_module_target(rel_path, &module_path)
2767            }
2768            .or_else(|| {
2769                self.conn
2770                    .query_row(
2771                        "SELECT d.dep_file
2772                         FROM file_dependencies d
2773                         JOIN files f ON f.path = d.dep_file
2774                         WHERE d.file_path = ?1
2775                         ORDER BY d.dep_file
2776                         LIMIT 1",
2777                        params![rel_path],
2778                        |row| row.get::<_, String>(0),
2779                    )
2780                    .optional()
2781                    .ok()
2782                    .flatten()
2783            });
2784            index
2785                .module_targets
2786                .entry(module_path.clone())
2787                .or_insert_with(|| target_file.clone());
2788            if kind == "module" {
2789                index
2790                    .declared_module_targets
2791                    .entry(module_path.clone())
2792                    .or_insert_with(|| target_file.clone());
2793            }
2794            if kind == "reexport" {
2795                let raw = RawRef {
2796                    ref_id,
2797                    caller_node: None,
2798                    caller_symbol: None,
2799                    caller_file: rel_path.to_string(),
2800                    kind,
2801                    short_name: None,
2802                    full_ref,
2803                    module_path: Some(module_path),
2804                    import_kind: Some("reexport".to_string()),
2805                    local_name: None,
2806                    requested_name: None,
2807                    namespace_alias: None,
2808                    wildcard,
2809                    line: 0,
2810                    byte_start: 0,
2811                    byte_end: 0,
2812                    dependencies: BTreeSet::new(),
2813                };
2814                index
2815                    .reexports
2816                    .push(reexport_index_from_raw(&raw, target_file));
2817            }
2818        }
2819        Some(index)
2820    }
2821
2822    fn disk_module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2823        let caller_dir = self.project_root.join(caller_file).parent()?.to_path_buf();
2824        let candidate = callgraph::resolve_module_path_with_memo(
2825            &caller_dir,
2826            module_path,
2827            self.module_resolution_memo,
2828            &FactPaths {
2829                root: self.project_root,
2830                facts: &DiskFacts::new(self.project_root),
2831            },
2832        )?;
2833        let rel_path = relative_path(self.project_root, &candidate);
2834        self.contains_file(&rel_path).then_some(rel_path)
2835    }
2836
2837    fn load_module_parent(&self, target_file: &str) -> Option<(String, String)> {
2838        let mut stmt = self
2839            .conn
2840            .prepare(
2841                "SELECT caller_file, module_path FROM refs
2842                 WHERE kind = 'module' AND module_path IS NOT NULL
2843                 ORDER BY caller_file, module_path",
2844            )
2845            .ok()?;
2846        let rows = stmt
2847            .query_map([], |row| {
2848                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2849            })
2850            .ok()?;
2851        for row in rows.flatten() {
2852            if self.module_target(&row.0, &row.1).as_deref() == Some(target_file) {
2853                return Some(row);
2854            }
2855        }
2856        None
2857    }
2858}
2859
2860impl ResolverIndex for DiskProjectIndex<'_> {
2861    fn caller_data(&self, file: &str) -> Option<&FileCallData> {
2862        (file == self.caller_file).then_some(self.caller_data)
2863    }
2864
2865    fn lang_for(&self, file: &str) -> Option<LangId> {
2866        self.file_index(file).and_then(|index| index.lang)
2867    }
2868
2869    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
2870        self.file_index(caller_file)
2871            .and_then(|index| index.module_targets.get(module_path).cloned().flatten())
2872    }
2873
2874    fn module_parent(&self, target_file: &str) -> Option<(String, String)> {
2875        if self.memoize_resolver_indexes {
2876            if let Some(cached) = self.module_parent_memo.borrow().get(target_file).cloned() {
2877                return cached;
2878            }
2879        }
2880
2881        let parent = self.load_module_parent(target_file);
2882        if self.memoize_resolver_indexes {
2883            let mut memo = self.module_parent_memo.borrow_mut();
2884            if memo.len() >= DISK_FILE_INDEX_MEMO_CAPACITY {
2885                memo.clear();
2886            }
2887            memo.insert(target_file.to_string(), parent.clone());
2888        }
2889        parent
2890    }
2891
2892    fn reexports_for(&self, file: &str) -> Vec<ReexportIndex> {
2893        self.file_index(file)
2894            .map(|index| index.reexports.clone())
2895            .unwrap_or_default()
2896    }
2897
2898    fn node_for_symbol(&self, file: &str, symbol: &str) -> Option<String> {
2899        self.file_index(file).and_then(|index| {
2900            index
2901                .node_by_scoped
2902                .get(symbol)
2903                .cloned()
2904                .or_else(|| index.node_by_bare.get(symbol).cloned())
2905        })
2906    }
2907
2908    fn node_is_callable(&self, file: &str, node_id: &str) -> bool {
2909        self.file_index(file)
2910            .and_then(|index| index.node_kind_by_id.get(node_id).cloned())
2911            .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
2912    }
2913
2914    fn export_alias(&self, file: &str, symbol: &str) -> Option<String> {
2915        self.file_index(file)
2916            .and_then(|index| index.export_aliases.get(symbol).cloned())
2917    }
2918
2919    fn has_export(&self, file: &str, symbol: &str) -> bool {
2920        self.file_index(file)
2921            .is_some_and(|index| index.exports.contains(symbol))
2922    }
2923
2924    fn default_export(&self, file: &str) -> Option<String> {
2925        self.file_index(file)
2926            .and_then(|index| index.default_export.clone())
2927    }
2928
2929    fn contains_file(&self, file: &str) -> bool {
2930        self.conn
2931            .query_row(
2932                "SELECT 1 FROM files WHERE path = ?1 LIMIT 1",
2933                params![file],
2934                |_| Ok(()),
2935            )
2936            .is_ok()
2937    }
2938
2939    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
2940        self.workspace_crate_prefixes
2941            .0
2942            .get_or_init(|| {
2943                build_workspace_crate_prefixes(
2944                    self.project_root,
2945                    &FactPaths {
2946                        root: self.project_root,
2947                        facts: &DiskFacts::new(self.project_root),
2948                    },
2949                )
2950            })
2951            .get(crate_name)
2952            .cloned()
2953    }
2954
2955    fn rust_crate_root_file(&self, caller_file: &str) -> Option<String> {
2956        let disk = DiskFacts::new(self.project_root);
2957        let paths = FactPaths {
2958            root: self.project_root,
2959            facts: &disk,
2960        };
2961        self.module_resolution_memo
2962            .rust_crate_root_file(
2963                self.project_root,
2964                &self.project_root.join(caller_file),
2965                &paths,
2966            )
2967            .map(|path| relative_path(self.project_root, &path))
2968    }
2969
2970    fn inline_scoped_target(
2971        &self,
2972        caller_file: &str,
2973        module_segments: &[String],
2974        short_name: &str,
2975    ) -> Option<(String, String)> {
2976        let src_prefix = rust_src_prefix(caller_file);
2977        let check = |file_path: String| {
2978            let file_module_segments = rust_module_segments_for_rel(&file_path);
2979            if rust_src_prefix(&file_path) != src_prefix
2980                || !module_segments.starts_with(&file_module_segments)
2981            {
2982                return None;
2983            }
2984            let scoped_segments = &module_segments[file_module_segments.len()..];
2985            if scoped_segments.is_empty() {
2986                return None;
2987            }
2988            let scoped_symbol = format!("{}::{short_name}", scoped_segments.join("::"));
2989            self.node_for_symbol(&file_path, &scoped_symbol)
2990                .map(|_| (file_path, scoped_symbol))
2991        };
2992        if let Some(target) = check(caller_file.to_string()) {
2993            return Some(target);
2994        }
2995        let mut statement = self
2996            .conn
2997            .prepare("SELECT path FROM files WHERE lang = 'rust' AND path <> ?1 ORDER BY path")
2998            .ok()?;
2999        let rows = statement
3000            .query_map(params![caller_file], |row| row.get::<_, String>(0))
3001            .ok()?;
3002        for path in rows.flatten() {
3003            if let Some(target) = check(path) {
3004                return Some(target);
3005            }
3006        }
3007        None
3008    }
3009}
3010
3011impl CallGraphStore {
3012    pub fn open_if_enabled(
3013        options: CallGraphStoreOptions,
3014        callgraph_dir: PathBuf,
3015        project_root: PathBuf,
3016    ) -> Result<Option<Self>> {
3017        if !options.enabled {
3018            return Ok(None);
3019        }
3020        Self::open(callgraph_dir, project_root).map(Some)
3021    }
3022
3023    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
3024        let project_key = crate::search_index::artifact_cache_key(&project_root);
3025        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3026        else {
3027            return Err(CallGraphStoreError::Unavailable(
3028                "writer capability denied; use the read-only callgraph opener".to_string(),
3029            ));
3030        };
3031        std::fs::create_dir_all(&callgraph_dir)?;
3032        // Resolve the current generation via the pointer (falling back to the
3033        // legacy single-file DB). If nothing is published yet, open the legacy
3034        // path so a brand-new store still gets a writable DB + schema.
3035        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
3036            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
3037        let OpenedStore { store, root_repair } = Self::open_at_path(
3038            project_root.clone(),
3039            project_key,
3040            sqlite_path,
3041            generation,
3042            true,
3043            Some(Arc::clone(&writer_lease)),
3044            None,
3045        )?;
3046        match root_repair {
3047            OpenRootRepair::NeedsRebuild { .. } => {
3048                log_root_repair_rebuild(&root_repair);
3049                drop(store);
3050                drop(writer_lease);
3051                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3052                let (store, _stats) =
3053                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3054                Ok(store)
3055            }
3056            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
3057        }
3058    }
3059
3060    pub fn open_readonly(
3061        callgraph_dir: PathBuf,
3062        project_root: PathBuf,
3063    ) -> Result<Option<ReadonlyCallGraphStore>> {
3064        let project_key = crate::search_index::artifact_cache_key(&project_root);
3065        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3066        {
3067            let conn = open_readonly_connection(&sqlite_path)?;
3068            if !database_ready(&conn).unwrap_or(false) {
3069                return Ok(None);
3070            }
3071            let marker_label = generation.as_deref().unwrap_or("legacy");
3072            let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, marker_label)?;
3073            return Ok(Some(ReadonlyCallGraphStore::from_inner(
3074                Self::from_connection(
3075                    project_root,
3076                    project_key,
3077                    sqlite_path,
3078                    callgraph_dir,
3079                    false,
3080                    generation,
3081                    None,
3082                    Some(read_marker),
3083                    conn,
3084                ),
3085            )));
3086        }
3087
3088        let Some(target) = freshest_legacy_fallback_target(&callgraph_dir, &project_key)? else {
3089            return Ok(None);
3090        };
3091        crate::slog_warn!(
3092            "root-keyed callgraph store is empty; serving read-only fallback from legacy {} partition {}",
3093            target.partition.harness,
3094            target.sqlite_path.display()
3095        );
3096        let conn = open_readonly_connection(&target.sqlite_path)?;
3097        if !database_ready(&conn).unwrap_or(false) {
3098            return Ok(None);
3099        }
3100        let marker_label =
3101            legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
3102        let read_marker = crate::root_cache::ReadMarker::create(&callgraph_dir, &marker_label)?;
3103        Ok(Some(ReadonlyCallGraphStore::from_inner(
3104            Self::from_connection(
3105                project_root,
3106                project_key,
3107                target.sqlite_path,
3108                callgraph_dir,
3109                true,
3110                target.generation,
3111                None,
3112                Some(read_marker),
3113                conn,
3114            ),
3115        )))
3116    }
3117
3118    /// Open the currently-published ready store with write access so moved-root
3119    /// metadata can be repaired before projection readers consume it. Unlike
3120    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
3121    /// ready generation exists, it returns `Ok(None)` instead of creating an
3122    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
3123    pub fn open_ready_repairing(
3124        callgraph_dir: PathBuf,
3125        project_root: PathBuf,
3126    ) -> Result<Option<Self>> {
3127        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, true, true)
3128    }
3129
3130    /// Open a ready store for bounded maintenance work without repairing root
3131    /// metadata or starting a cold rebuild. A store that needs either action is
3132    /// reported as unavailable so a background build can own that work.
3133    pub fn open_ready(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
3134        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, false)
3135    }
3136
3137    pub fn open_ready_no_rebuild(
3138        callgraph_dir: PathBuf,
3139        project_root: PathBuf,
3140    ) -> Result<Option<Self>> {
3141        Self::open_ready_with_rebuild_policy(callgraph_dir, project_root, false, true)
3142    }
3143
3144    fn open_ready_with_rebuild_policy(
3145        callgraph_dir: PathBuf,
3146        project_root: PathBuf,
3147        allow_cold_build: bool,
3148        allow_root_repair: bool,
3149    ) -> Result<Option<Self>> {
3150        let project_key = crate::search_index::artifact_cache_key(&project_root);
3151        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3152        else {
3153            return Ok(None);
3154        };
3155        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3156        else {
3157            return Ok(None);
3158        };
3159        let OpenedStore { store, root_repair } = Self::open_at_path_with_root_repair(
3160            project_root.clone(),
3161            project_key.clone(),
3162            sqlite_path,
3163            generation,
3164            true,
3165            Some(Arc::clone(&writer_lease)),
3166            None,
3167            allow_root_repair,
3168        )?;
3169        match root_repair {
3170            OpenRootRepair::NeedsRebuild { .. } if allow_cold_build => {
3171                log_root_repair_rebuild(&root_repair);
3172                drop(store);
3173                drop(writer_lease);
3174                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3175                let (store, _stats) =
3176                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
3177                Ok(Some(store))
3178            }
3179            OpenRootRepair::NeedsRebuild { .. } => {
3180                if let Some(message) = note_repair_entry(&project_key) {
3181                    crate::slog_warn!("{message}");
3182                }
3183                Ok(None)
3184            }
3185            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3186        }
3187    }
3188
3189    pub fn cold_build_with_lease(
3190        callgraph_dir: PathBuf,
3191        project_root: PathBuf,
3192        files: &[PathBuf],
3193    ) -> Result<(Self, ColdBuildStats)> {
3194        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
3195    }
3196
3197    pub fn cold_build_with_lease_chunked(
3198        callgraph_dir: PathBuf,
3199        project_root: PathBuf,
3200        files: &[PathBuf],
3201        chunk_size: usize,
3202    ) -> Result<(Self, ColdBuildStats)> {
3203        Self::cold_build_with_lease_chunked_inner(
3204            callgraph_dir,
3205            project_root,
3206            files,
3207            chunk_size,
3208            false,
3209        )
3210    }
3211
3212    pub(crate) fn force_cold_build_with_lease_chunked(
3213        callgraph_dir: PathBuf,
3214        project_root: PathBuf,
3215        files: &[PathBuf],
3216        chunk_size: usize,
3217    ) -> Result<(Self, ColdBuildStats)> {
3218        Self::cold_build_with_lease_chunked_inner(
3219            callgraph_dir,
3220            project_root,
3221            files,
3222            chunk_size,
3223            true,
3224        )
3225    }
3226
3227    fn cold_build_with_lease_chunked_inner(
3228        callgraph_dir: PathBuf,
3229        project_root: PathBuf,
3230        files: &[PathBuf],
3231        chunk_size: usize,
3232        require_new_publication: bool,
3233    ) -> Result<(Self, ColdBuildStats)> {
3234        let project_key = crate::search_index::artifact_cache_key(&project_root);
3235        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3236        else {
3237            let operation = if require_new_publication {
3238                "forced rebuild"
3239            } else {
3240                "cold build"
3241            };
3242            return Err(CallGraphStoreError::Unavailable(format!(
3243                "{operation} could not acquire writer capability"
3244            )));
3245        };
3246        std::fs::create_dir_all(&callgraph_dir)?;
3247        let (stats, generation) = Self::cold_build_publish_locked(
3248            &callgraph_dir,
3249            &project_root,
3250            &project_key,
3251            files,
3252            chunk_size,
3253            Arc::clone(&writer_lease),
3254        )?;
3255        let store = Self::open_generation(
3256            &callgraph_dir,
3257            project_root,
3258            project_key,
3259            generation,
3260            writer_lease,
3261        )?;
3262        Ok((store, stats))
3263    }
3264
3265    pub fn ensure_built_with_lease(
3266        callgraph_dir: PathBuf,
3267        project_root: PathBuf,
3268        files: &[PathBuf],
3269    ) -> Result<(Self, Option<ColdBuildStats>)> {
3270        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
3271    }
3272
3273    pub fn ensure_built_with_lease_chunked(
3274        callgraph_dir: PathBuf,
3275        project_root: PathBuf,
3276        files: &[PathBuf],
3277        chunk_size: usize,
3278    ) -> Result<(Self, Option<ColdBuildStats>)> {
3279        let project_key = crate::search_index::artifact_cache_key(&project_root);
3280        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3281        else {
3282            return Err(CallGraphStoreError::Unavailable(
3283                "callgraph ensure could not acquire writer capability".to_string(),
3284            ));
3285        };
3286        std::fs::create_dir_all(&callgraph_dir)?;
3287        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3288        // Another process may have published a ready generation while we waited
3289        // for the lock — open it instead of rebuilding. If that generation is
3290        // from this same project at an older filesystem root, repair the root
3291        // metadata in-place while still holding the build lease. If data rows
3292        // contain absolute paths, publish a fresh generation under this lease
3293        // rather than recursively reacquiring the same lock.
3294        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3295        {
3296            let OpenedStore { store, root_repair } = Self::open_at_path(
3297                project_root.clone(),
3298                project_key.clone(),
3299                sqlite_path,
3300                generation,
3301                true,
3302                Some(Arc::clone(&writer_lease)),
3303                None,
3304            )?;
3305            match root_repair {
3306                OpenRootRepair::NeedsRebuild { .. } => {
3307                    log_root_repair_rebuild(&root_repair);
3308                    drop(store);
3309                    let (stats, generation) = Self::cold_build_publish_locked(
3310                        &callgraph_dir,
3311                        &project_root,
3312                        &project_key,
3313                        files,
3314                        chunk_size,
3315                        Arc::clone(&writer_lease),
3316                    )?;
3317                    let store = Self::open_generation(
3318                        &callgraph_dir,
3319                        project_root,
3320                        project_key,
3321                        generation,
3322                        writer_lease,
3323                    )?;
3324                    return Ok((store, Some(stats)));
3325                }
3326                OpenRootRepair::None | OpenRootRepair::ReRooted => {
3327                    return Ok((store, None));
3328                }
3329            }
3330        }
3331        if let Some(store) = try_legacy_migration_or_fallback(
3332            &callgraph_dir,
3333            &project_root,
3334            &project_key,
3335            Arc::clone(&writer_lease),
3336        )? {
3337            return Ok((store, None));
3338        }
3339        let (stats, generation) = Self::cold_build_publish_locked(
3340            &callgraph_dir,
3341            &project_root,
3342            &project_key,
3343            files,
3344            chunk_size,
3345            Arc::clone(&writer_lease),
3346        )?;
3347        let store = Self::open_generation(
3348            &callgraph_dir,
3349            project_root,
3350            project_key,
3351            generation,
3352            writer_lease,
3353        )?;
3354        Ok((store, Some(stats)))
3355    }
3356
3357    /// Migrate a legacy harness-partition store without falling through to a
3358    /// cold build. This is used after a query has already opened a read-only
3359    /// fallback: the caller runs it on the same limited background lane as cold
3360    /// builds while queries continue using that fallback. Public so crash/retry
3361    /// tests can drive the migration synchronously on a thread where the
3362    /// thread-local failure seams apply.
3363    pub fn migrate_legacy_with_lease(
3364        callgraph_dir: PathBuf,
3365        project_root: PathBuf,
3366    ) -> Result<Option<Self>> {
3367        let project_key = crate::search_index::artifact_cache_key(&project_root);
3368        let Some(writer_lease) = acquire_writer_lease(&callgraph_dir, &project_key, &project_root)?
3369        else {
3370            return Ok(None);
3371        };
3372        std::fs::create_dir_all(&callgraph_dir)?;
3373        cleanup_incomplete_migrations(&callgraph_dir, &project_key);
3374
3375        // Another writer may have completed the migration while this worker was
3376        // waiting for the lease. Adopt its root-keyed generation rather than
3377        // copying the legacy source a second time.
3378        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
3379        {
3380            let OpenedStore { store, root_repair } = Self::open_at_path(
3381                project_root,
3382                project_key,
3383                sqlite_path,
3384                generation,
3385                true,
3386                Some(writer_lease),
3387                None,
3388            )?;
3389            return match root_repair {
3390                OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
3391                OpenRootRepair::NeedsRebuild { reason, .. } => {
3392                    Err(CallGraphStoreError::Unavailable(format!(
3393                        "root-keyed store discovered during legacy migration requires a cold rebuild: {reason}"
3394                    )))
3395                }
3396            };
3397        }
3398
3399        let store = try_legacy_migration_or_fallback(
3400            &callgraph_dir,
3401            &project_root,
3402            &project_key,
3403            writer_lease,
3404        )?;
3405        // A disk-floor or backup-budget failure returns a readable legacy store.
3406        // Keep the already-resident fallback instead of sending this duplicate
3407        // reader through the background-install channel.
3408        Ok(store.filter(|store| !store.is_legacy_fallback()))
3409    }
3410
3411    /// Build a fresh DB and publish it as a new generation, then atomically flip
3412    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
3413    /// succeeds even when other processes hold an older generation open (the
3414    /// multi-TUI Windows case). The builder owns the temp + generation files
3415    /// exclusively (unique pid+nanos names), so it can rename/replace them
3416    /// freely; only the tiny pointer is shared, and only Rust std touches it.
3417    ///
3418    /// Returns the published generation file name so callers open exactly the
3419    /// generation they built (avoiding a race where a concurrent build's flip
3420    /// would otherwise reopen a different generation).
3421    fn cold_build_publish_locked(
3422        callgraph_dir: &Path,
3423        project_root: &Path,
3424        project_key: &str,
3425        files: &[PathBuf],
3426        chunk_size: usize,
3427        writer_lease: Arc<crate::root_cache::WriterLease>,
3428    ) -> Result<(ColdBuildStats, String)> {
3429        if let Some((previous_root, remaining)) =
3430            rebuild_cooldown_denial(callgraph_dir, project_key, project_root, Instant::now())
3431        {
3432            return Err(CallGraphStoreError::Unavailable(format!(
3433                "cache key {project_key} was rebuilt for {} too recently; retry {} ms after the per-key cooldown",
3434                previous_root.display(),
3435                remaining.as_millis()
3436            )));
3437        }
3438        let breaker = crate::build_breaker::BuildDeathBreaker::open(
3439            callgraph_dir.join("build-breaker.sqlite"),
3440        )
3441        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3442
3443        let generation = generation_file_name(project_key);
3444        let gen_path = callgraph_dir.join(&generation);
3445        // A writer lease makes this root/domain's staging generation exclusive.
3446        // Keep its identity stable so a replacement process adopts committed
3447        // batches instead of minting a second temp and starting from zero.
3448        let temp_path = callgraph_dir.join(format!("{project_key}.staging.sqlite.tmp.resume"));
3449        let adopting_staging = temp_path.exists();
3450        if !adopting_staging {
3451            remove_sqlite_file_set(&temp_path);
3452        }
3453
3454        let scope = crate::logging::IndexBuildScope::new(
3455            crate::logging::IndexPlane::Callgraph,
3456            project_root,
3457            project_key,
3458        );
3459        let _index_build = crate::logging::install_index_build(scope.clone());
3460        let mut failure_guard = crate::logging::IndexBuildFailureGuard::new();
3461        let mut started = crate::logging::IndexEvent::from_scope(
3462            crate::logging::IndexEventKind::BuildStarted,
3463            &scope,
3464        );
3465        if adopting_staging {
3466            started = started.field("resumed_from_staging", "true");
3467        }
3468        crate::logging::log_index_event(started);
3469
3470        let (stats, breaker_key) = {
3471            if adopting_staging {
3472                crate::slog_info!(
3473                    "resuming callgraph cold build from staged generation {}",
3474                    temp_path.display()
3475                );
3476            }
3477            let temp_store = Self::open_at_path(
3478                project_root.to_path_buf(),
3479                project_key.to_string(),
3480                temp_path.clone(),
3481                None,
3482                false,
3483                Some(Arc::clone(&writer_lease)),
3484                None,
3485            )?
3486            .store;
3487            // Admission must precede every expensive build phase and every
3488            // staging write: a suspended root is refused before the process
3489            // spends anything, and a death during enumeration is attributable
3490            // to an admitted attempt. The breaker key needs the corpus
3491            // fingerprint, so that one input is resolved by a standalone
3492            // streaming walk first (sanctioned pre-admission work) - the
3493            // inventory pass below recomputes it while staging; the staged
3494            // value governs resume cursors, while the admission key stays
3495            // pinned to the admitted fingerprint so a file racing the walk
3496            // cannot detach the attempt from its breaker record.
3497            let admission_fingerprint = corpus_fingerprint_for(project_root, files)?;
3498            let breaker_key = crate::build_breaker::BreakerKey::new(
3499                project_root.display().to_string(),
3500                crate::build_breaker::BuildDomain::CallgraphCold,
3501                admission_fingerprint,
3502            );
3503            match breaker
3504                .admit(&breaker_key, 0)
3505                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
3506            {
3507                crate::build_breaker::BreakerAdmission::Admitted(_) => {
3508                    crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3509                        crate::logging::IndexEventKind::BreakerAdmitted,
3510                        &scope,
3511                    ));
3512                }
3513                crate::build_breaker::BreakerAdmission::Suspended(suspension) => {
3514                    crate::logging::log_index_event(
3515                        crate::logging::IndexEvent::from_scope(
3516                            crate::logging::IndexEventKind::BuildSuspended,
3517                            &scope,
3518                        )
3519                        .field("reason", &suspension.reason),
3520                    );
3521                    failure_guard.disarm();
3522                    return Err(CallGraphStoreError::Suspended(suspension));
3523                }
3524            }
3525            ensure_cold_build_current("inventory", 0, 1)?;
3526            let corpus_fingerprint = temp_store.stage_cold_build_file_inventory(files)?;
3527            ensure_cold_build_current("inventory", 1, 1)?;
3528            let stats = temp_store
3529                .cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)?;
3530            let _ = temp_store.checkpoint_wal_truncate();
3531            temp_store.prepare_for_atomic_swap()?;
3532            (stats, breaker_key)
3533        };
3534
3535        notify_cold_build_before_publish_observer();
3536        let publication = publish_if_current(|| {
3537            verify_writer_lease(&writer_lease)?;
3538            // Move the finished build to its final generation path. This target is
3539            // brand-new and owned by us, so the rename never hits an open file.
3540            remove_sqlite_file_set(&gen_path);
3541            crate::fs_lock::rename_over(&temp_path, &gen_path)?;
3542            crate::fs_lock::sync_parent(&gen_path);
3543            remove_sqlite_sidecars(&gen_path);
3544
3545            notify_cold_build_swap_observer(&temp_path, &gen_path);
3546
3547            // Atomically publish the new generation, then best-effort GC old ones.
3548            verify_writer_lease(&writer_lease)?;
3549            publish_pointer(callgraph_dir, project_key, &generation)?;
3550            gc_old_generations(callgraph_dir, project_key, &generation);
3551            // Store-wide orphan sweep on the same cadence: reclaims aged build
3552            // temps for roots that no longer build here, which the per-root GC
3553            // above never reaches.
3554            sweep_orphaned_build_temps_store_wide(callgraph_dir);
3555            sweep_orphaned_callgraph_root_dirs(callgraph_dir);
3556            crate::search_index::sweep_transient_search_cache_dirs();
3557            if let Some(storage_root) = root_storage_dir(callgraph_dir) {
3558                let inspect_root =
3559                    storage_root.join(crate::root_cache::RootCacheDomain::Inspect.as_str());
3560                let live_scope_keys = crate::root_cache::live_scope_keys_for_storage(&storage_root);
3561                crate::inspect::cache::sweep_inspect_scope_dirs(&inspect_root, &live_scope_keys);
3562            }
3563            Ok(())
3564        });
3565        // A superseded generation remains a valid resumable staging artifact.
3566        // Its successor compares the durable corpus fingerprint before either
3567        // adopting this work or resetting it for a changed corpus.
3568        if let Err(CallGraphStoreError::Superseded) = &publication {
3569            crate::logging::log_index_event(
3570                crate::logging::IndexEvent::from_scope(
3571                    crate::logging::IndexEventKind::BuildSuperseded,
3572                    &scope,
3573                )
3574                .field("stage", "publish"),
3575            );
3576            failure_guard.disarm();
3577        }
3578        publication?;
3579        // Pointer publication is the only automatic breaker reset. The staging
3580        // batches above never reset history because a process can die after them.
3581        breaker
3582            .record_ready_publication(&breaker_key)
3583            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
3584        crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
3585            crate::logging::IndexEventKind::BreakerReset,
3586            &scope,
3587        ));
3588        record_successful_rebuild(callgraph_dir, project_key, project_root, Instant::now());
3589        crate::logging::log_index_event(
3590            crate::logging::IndexEvent::from_scope(
3591                crate::logging::IndexEventKind::BuildReady,
3592                &scope,
3593            )
3594            .field("elapsed_ms", scope.elapsed_ms())
3595            .field("files", stats.files)
3596            .field("edges", stats.edges),
3597        );
3598        failure_guard.disarm();
3599        Ok((stats, generation))
3600    }
3601
3602    /// Open a specific just-published generation (read-write, WAL) so a builder
3603    /// returns a store pinned to exactly what it built.
3604    fn open_generation(
3605        callgraph_dir: &Path,
3606        project_root: PathBuf,
3607        project_key: String,
3608        generation: String,
3609        writer_lease: Arc<crate::root_cache::WriterLease>,
3610    ) -> Result<Self> {
3611        let gen_path = callgraph_dir.join(&generation);
3612        Ok(Self::open_at_path(
3613            project_root,
3614            project_key,
3615            gen_path,
3616            Some(generation),
3617            true,
3618            Some(writer_lease),
3619            None,
3620        )?
3621        .store)
3622    }
3623
3624    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
3625        let project_key = crate::search_index::artifact_cache_key(project_root);
3626        // A cold build is needed unless a ready generation (or ready legacy DB)
3627        // is currently published.
3628        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
3629    }
3630
3631    /// Check the durable callgraph-domain breaker before a query starts a cold
3632    /// worker. This only runs while no ready generation exists; it never builds
3633    /// inline and lets a tripped root return a terminal answer instead of an
3634    /// endless `Building` response.
3635    pub fn cold_build_suspension(
3636        callgraph_dir: &Path,
3637        project_root: &Path,
3638    ) -> Result<Option<crate::build_breaker::BuildSuspension>> {
3639        let breaker_path = callgraph_dir.join("build-breaker.sqlite");
3640        if !breaker_path.exists() {
3641            return Ok(None);
3642        }
3643        let key = crate::build_breaker::BreakerKey::new(
3644            project_root.display().to_string(),
3645            crate::build_breaker::BuildDomain::CallgraphCold,
3646            callgraph_corpus_fingerprint(project_root)?,
3647        );
3648        crate::build_breaker::BuildDeathBreaker::open(breaker_path)
3649            .and_then(|breaker| breaker.suspension(&key))
3650            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))
3651    }
3652
3653    fn open_at_path(
3654        project_root: PathBuf,
3655        project_key: String,
3656        sqlite_path: PathBuf,
3657        generation: Option<String>,
3658        use_wal: bool,
3659        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3660        read_marker: Option<crate::root_cache::ReadMarker>,
3661    ) -> Result<OpenedStore> {
3662        Self::open_at_path_with_root_repair(
3663            project_root,
3664            project_key,
3665            sqlite_path,
3666            generation,
3667            use_wal,
3668            writer_lease,
3669            read_marker,
3670            true,
3671        )
3672    }
3673
3674    fn open_at_path_with_root_repair(
3675        project_root: PathBuf,
3676        project_key: String,
3677        sqlite_path: PathBuf,
3678        generation: Option<String>,
3679        use_wal: bool,
3680        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3681        read_marker: Option<crate::root_cache::ReadMarker>,
3682        allow_root_repair: bool,
3683    ) -> Result<OpenedStore> {
3684        if let Some(lease) = writer_lease.as_ref() {
3685            verify_writer_lease(lease)?;
3686        }
3687        if let Some(parent) = sqlite_path.parent() {
3688            std::fs::create_dir_all(parent)?;
3689        }
3690        let mut conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration)?;
3691        if use_wal {
3692            configure_connection(&conn)?;
3693        } else {
3694            configure_build_connection(&conn)?;
3695        }
3696        if let Some(lease) = writer_lease.as_ref() {
3697            verify_writer_lease(lease)?;
3698        }
3699        initialize_schema(&conn)?;
3700        if let Some(lease) = writer_lease.as_ref() {
3701            verify_writer_lease(lease)?;
3702        }
3703        let root_repair = reconcile_workspace_roots(&mut conn, &project_root, allow_root_repair)?;
3704        let read_marker = match (read_marker, generation.as_deref(), sqlite_path.parent()) {
3705            (Some(marker), _, _) => Some(marker),
3706            (None, Some(label), Some(cache_dir)) => {
3707                Some(crate::root_cache::ReadMarker::create(cache_dir, label)?)
3708            }
3709            (None, _, _) => None,
3710        };
3711        let publication_dir = sqlite_path
3712            .parent()
3713            .map(Path::to_path_buf)
3714            .unwrap_or_default();
3715        let store = Self::from_connection(
3716            project_root,
3717            project_key,
3718            sqlite_path,
3719            publication_dir,
3720            false,
3721            generation,
3722            writer_lease,
3723            read_marker,
3724            conn,
3725        );
3726        Ok(OpenedStore { store, root_repair })
3727    }
3728
3729    fn prepare_for_atomic_swap(&self) -> Result<()> {
3730        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3731        conn.execute_batch(self.atomic_swap_checkpoint_sql())?;
3732        Ok(())
3733    }
3734
3735    fn atomic_swap_checkpoint_sql(&self) -> &'static str {
3736        let protected_reader = self.generation.as_deref().is_some_and(|generation| {
3737            self.sqlite_path
3738                .parent()
3739                .is_some_and(|dir| crate::root_cache::protected_read_marker_exists(dir, generation))
3740        });
3741        if protected_reader {
3742            "PRAGMA wal_checkpoint(PASSIVE); PRAGMA journal_mode=DELETE;"
3743        } else {
3744            "PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;"
3745        }
3746    }
3747
3748    fn from_connection(
3749        project_root: PathBuf,
3750        project_key: String,
3751        sqlite_path: PathBuf,
3752        publication_dir: PathBuf,
3753        legacy_fallback: bool,
3754        generation: Option<String>,
3755        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
3756        read_marker: Option<crate::root_cache::ReadMarker>,
3757        conn: TrackedConnection,
3758    ) -> Self {
3759        let write_metrics = callgraph_write_metrics_for_key(&project_key);
3760        Self {
3761            project_root,
3762            project_key,
3763            sqlite_path,
3764            publication_dir,
3765            legacy_fallback,
3766            manifest_view: false,
3767            generation,
3768            writer_lease,
3769            read_marker,
3770            database_ready: AtomicBool::new(false),
3771            write_metrics,
3772            conn: Mutex::new(conn),
3773        }
3774    }
3775
3776    fn ensure_ready(&self, conn: &Connection) -> Result<()> {
3777        if self.database_ready.load(AtomicOrdering::Acquire) {
3778            return Ok(());
3779        }
3780        ensure_database_ready(conn)?;
3781        self.database_ready.store(true, AtomicOrdering::Release);
3782        Ok(())
3783    }
3784
3785    pub fn project_root(&self) -> &Path {
3786        &self.project_root
3787    }
3788
3789    pub fn project_key(&self) -> &str {
3790        &self.project_key
3791    }
3792
3793    pub fn sqlite_path(&self) -> &Path {
3794        &self.sqlite_path
3795    }
3796
3797    /// The generation file named by the publication pointer when this store opened.
3798    pub(crate) fn projection_generation(&self) -> Option<&str> {
3799        self.generation.as_deref()
3800    }
3801
3802    /// Read the durable revision that changes in the same transaction as graph writes.
3803    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
3804        self.refresh_read_marker()?;
3805        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3806        self.ensure_ready(&conn)?;
3807        projection_write_revision(&conn)
3808    }
3809
3810    /// Whether this store is reading from a legacy harness partition because
3811    /// the root-keyed store has not published a generation yet.
3812    pub fn is_legacy_fallback(&self) -> bool {
3813        self.legacy_fallback
3814    }
3815
3816    pub(crate) fn is_legacy_migration(&self) -> bool {
3817        self.generation.as_deref().is_some_and(|generation| {
3818            migration_generation_requires_manifest(generation)
3819                && migration_manifest_valid(&self.publication_dir, generation)
3820        })
3821    }
3822
3823    pub fn writer_epoch_for_test(&self) -> Option<&str> {
3824        self.writer_lease.as_ref().map(|lease| lease.epoch())
3825    }
3826
3827    fn verify_writer_lease(&self) -> Result<()> {
3828        let Some(lease) = self.writer_lease.as_ref() else {
3829            return Err(CallGraphStoreError::Unavailable(
3830                "callgraph store opened read-only; write API is unavailable".to_string(),
3831            ));
3832        };
3833        verify_writer_lease(lease)
3834    }
3835
3836    fn refresh_read_marker(&self) -> Result<()> {
3837        if let Some(marker) = self.read_marker.as_ref() {
3838            marker.touch_if_due()?;
3839        }
3840        Ok(())
3841    }
3842
3843    fn record_commit(&self, total_changes_before: u64, conn: &Connection) {
3844        self.write_metrics
3845            .record_commit(conn.total_changes().saturating_sub(total_changes_before));
3846    }
3847
3848    fn checkpoint_wal_truncate(&self) -> bool {
3849        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
3850        checkpoint_wal_truncate(&conn)
3851    }
3852
3853    /// True if this store still reflects the currently-published generation.
3854    /// Cheap (one small pointer-file read). When false, another process (or a
3855    /// local cold rebuild) has published a newer generation and the holder
3856    /// should drop this store and reopen via the pointer to converge. A missing
3857    /// pointer keeps the current store (legacy DB still valid, or transient).
3858    pub fn is_current(&self) -> bool {
3859        let _ = self.refresh_read_marker();
3860        match (
3861            read_pointer(&self.publication_dir, &self.project_key),
3862            &self.generation,
3863        ) {
3864            // Even when both generations happen to have the same filename, the
3865            // root-keyed pointer names a different directory from the fallback.
3866            (Some(_), _) if self.legacy_fallback => false,
3867            (Some(published), Some(opened)) => &published == opened,
3868            // A generation now supersedes the legacy single-file DB we opened.
3869            (Some(_), None) => false,
3870            // No pointer: keep serving (legacy DB, or an anomalous pointer
3871            // removal where our open generation file is still valid).
3872            (None, _) => true,
3873        }
3874    }
3875
3876    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
3877        self.cold_build_chunked(files, COLD_BUILD_EXTRACT_BATCH_FILES)
3878    }
3879
3880    /// Build in two durable passes. Discovery first commits a disk-backed file
3881    /// inventory, extraction consumes bounded batches from that inventory, and
3882    /// resolution pages through staged raw references after all symbols exist.
3883    pub fn cold_build_chunked(
3884        &self,
3885        files: &[PathBuf],
3886        chunk_size: usize,
3887    ) -> Result<ColdBuildStats> {
3888        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3889        self.cold_build_chunked_from_staged_inventory(chunk_size, &corpus_fingerprint)
3890    }
3891
3892    fn stage_cold_build_file_inventory(&self, files: &[PathBuf]) -> Result<String> {
3893        note_cold_build_phase("enumeration");
3894        if files.is_empty() {
3895            self.stage_cold_build_file_inventory_from(callgraph::walk_project_files(
3896                &self.project_root,
3897            ))
3898        } else {
3899            self.stage_cold_build_file_inventory_from(files.iter().cloned())
3900        }
3901    }
3902
3903    fn stage_cold_build_file_inventory_from<I>(&self, paths: I) -> Result<String>
3904    where
3905        I: IntoIterator<Item = PathBuf>,
3906    {
3907        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
3908        self.verify_writer_lease()?;
3909        let total_changes_before = conn.total_changes();
3910        let tx = conn.transaction()?;
3911        tx.execute("DELETE FROM staging_file_inventory", [])?;
3912        tx.commit()?;
3913        self.record_commit(total_changes_before, &conn);
3914
3915        let mut batch = Vec::with_capacity(COLD_BUILD_EXTRACT_BATCH_FILES);
3916        for path in paths {
3917            let path = normalize_file_path(&self.project_root, &path)?;
3918            let rel_path = relative_path(&self.project_root, &path);
3919            let size = std::fs::metadata(&path)
3920                .map(|metadata| metadata.len())
3921                .unwrap_or(0);
3922            batch.push((rel_path, size));
3923            if batch.len() == COLD_BUILD_EXTRACT_BATCH_FILES {
3924                self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3925                batch.clear();
3926            }
3927        }
3928        if !batch.is_empty() {
3929            self.insert_staged_file_inventory_batch(&mut conn, &batch)?;
3930        }
3931
3932        staged_corpus_fingerprint(&conn, &self.project_root)
3933    }
3934
3935    fn insert_staged_file_inventory_batch(
3936        &self,
3937        conn: &mut Connection,
3938        batch: &[(String, u64)],
3939    ) -> Result<()> {
3940        self.verify_writer_lease()?;
3941        let total_changes_before = conn.total_changes();
3942        let tx = conn.transaction()?;
3943        {
3944            let mut insert = tx.prepare(
3945                "INSERT OR REPLACE INTO staging_file_inventory(path, size) VALUES(?1, ?2)",
3946            )?;
3947            for (path, size) in batch {
3948                insert.execute(params![path, *size as i64])?;
3949            }
3950        }
3951        tx.commit()?;
3952        self.record_commit(total_changes_before, conn);
3953        Ok(())
3954    }
3955
3956    fn cold_build_chunked_from_staged_inventory(
3957        &self,
3958        chunk_size: usize,
3959        corpus_fingerprint: &str,
3960    ) -> Result<ColdBuildStats> {
3961        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
3962        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3963            chunk_size,
3964            corpus_fingerprint,
3965            COLD_BUILD_RESOLVE_WINDOW,
3966            &module_resolution_memo,
3967            true,
3968        )
3969    }
3970
3971    #[cfg(test)]
3972    fn cold_build_chunked_with_resolution_memo_for_test(
3973        &self,
3974        files: &[PathBuf],
3975        chunk_size: usize,
3976        resolve_window: usize,
3977        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3978    ) -> Result<ColdBuildStats> {
3979        self.cold_build_chunked_with_disk_index_memo_for_test(
3980            files,
3981            chunk_size,
3982            resolve_window,
3983            module_resolution_memo,
3984            true,
3985        )
3986    }
3987
3988    #[cfg(test)]
3989    fn cold_build_chunked_with_disk_index_memo_for_test(
3990        &self,
3991        files: &[PathBuf],
3992        chunk_size: usize,
3993        resolve_window: usize,
3994        module_resolution_memo: &callgraph::ModuleResolutionMemo,
3995        memoize_resolver_indexes: bool,
3996    ) -> Result<ColdBuildStats> {
3997        let corpus_fingerprint = self.stage_cold_build_file_inventory(files)?;
3998        self.cold_build_chunked_from_staged_inventory_with_resolution_memo(
3999            chunk_size,
4000            &corpus_fingerprint,
4001            resolve_window.max(1),
4002            module_resolution_memo,
4003            memoize_resolver_indexes,
4004        )
4005    }
4006
4007    fn cold_build_chunked_from_staged_inventory_with_resolution_memo(
4008        &self,
4009        chunk_size: usize,
4010        corpus_fingerprint: &str,
4011        resolve_window: usize,
4012        module_resolution_memo: &callgraph::ModuleResolutionMemo,
4013        memoize_resolver_indexes: bool,
4014    ) -> Result<ColdBuildStats> {
4015        let started = Instant::now();
4016        let batch_files = chunk_size.max(1).min(COLD_BUILD_EXTRACT_BATCH_FILES);
4017        let workspace_root = self.project_root.display().to_string();
4018        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4019
4020        self.verify_writer_lease()?;
4021        ensure_cold_build_current("staging-admission", 0, 1)?;
4022        let mut phase = staged_build_phase(&conn)?;
4023        let staged_fingerprint = staged_string(&conn, STAGED_CORPUS_FINGERPRINT)?;
4024        let fingerprint_matches = staged_fingerprint.as_deref() == Some(corpus_fingerprint);
4025        if phase.as_deref() == Some("ready") && fingerprint_matches {
4026            ensure_cold_build_current("completed-staging", 1, 1)?;
4027            crate::slog_info!(
4028                "callgraph cold-build decision: reason=matching completed staging; action=publish"
4029            );
4030            conn.execute("DELETE FROM staging_file_inventory", [])?;
4031            return cold_build_stats_from_connection(&conn, started);
4032        }
4033        if phase.is_none() || !fingerprint_matches {
4034            if staged_fingerprint.is_some() && !fingerprint_matches {
4035                crate::slog_info!(
4036                    "callgraph cold-build decision: reason=fingerprint mismatch; action=restart staging"
4037                );
4038            }
4039            let total_changes_before = conn.total_changes();
4040            let tx = conn.transaction()?;
4041            clear_tables(&tx)?;
4042            tx.execute("DELETE FROM staging_ref_context", [])?;
4043            insert_meta(&tx)?;
4044            drop_cold_build_secondary_indexes(&tx)?;
4045            set_meta_ready(&tx, false)?;
4046            set_staged_build_phase(&tx, "extracting")?;
4047            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, corpus_fingerprint)?;
4048            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0)?;
4049            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, 0)?;
4050            tx.commit()?;
4051            self.record_commit(total_changes_before, &conn);
4052            phase = Some("extracting".to_string());
4053        }
4054
4055        // A crashed extraction pass has already committed complete batches. Compare the
4056        // staged content identity with the current file before parsing so unchanged
4057        // committed files are not restarted from zero after adoption.
4058        note_cold_build_phase("extraction");
4059        if phase.as_deref() == Some("extracting") {
4060            prune_staged_files_not_in_inventory(&mut conn)?;
4061
4062            let total_files =
4063                query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory")? as usize;
4064            let mut completed_files = 0usize;
4065            ensure_cold_build_current("extraction", completed_files, total_files)?;
4066            let mut after_path = String::new();
4067            loop {
4068                let Some(batch) = load_staged_file_batch(
4069                    &conn,
4070                    &self.project_root,
4071                    &after_path,
4072                    batch_files,
4073                    COLD_BUILD_EXTRACT_BATCH_BYTES,
4074                )?
4075                else {
4076                    break;
4077                };
4078                after_path = batch.last_path;
4079                let batch_files = batch.paths.len();
4080
4081                let mut needs_extract = Vec::with_capacity(batch_files);
4082                for path in batch.paths {
4083                    if !staged_content_matches(&conn, &self.project_root, &path)? {
4084                        needs_extract.push(path);
4085                    }
4086                }
4087                if needs_extract.is_empty() {
4088                    completed_files = completed_files.saturating_add(batch_files);
4089                    ensure_cold_build_current("extraction", completed_files, total_files)?;
4090                    continue;
4091                }
4092
4093                notify_cold_build_extract_observer(&needs_extract);
4094                let build = build_extracts_parallel(&self.project_root, &needs_extract);
4095                self.verify_writer_lease()?;
4096                let total_changes_before = conn.total_changes();
4097                let tx = conn.transaction()?;
4098                let mut extracted_bytes = 0u64;
4099                {
4100                    let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4101                    for extract in &build.extracts {
4102                        delete_staged_file_rows(&tx, &extract.rel_path)?;
4103                        insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4104                        for raw in &extract.raw_refs {
4105                            insert_staged_ref_prepared(&mut inserts, raw)?;
4106                        }
4107                        extracted_bytes = extracted_bytes.saturating_add(extract.freshness.size);
4108                    }
4109                    for failure in &build.failures {
4110                        insert_backend_state_prepared(
4111                            &mut inserts.backend_state,
4112                            &workspace_root,
4113                            &failure.rel_path,
4114                            failure
4115                                .freshness
4116                                .as_ref()
4117                                .map(|freshness| &freshness.content_hash),
4118                            "stale",
4119                        )?;
4120                    }
4121                }
4122                increment_staged_extracted_bytes(&tx, extracted_bytes)?;
4123                note_cold_build_commit_barrier("extraction_batch_before_commit");
4124                tx.commit()?;
4125                note_cold_build_commit_barrier("extraction_batch_committed");
4126                self.record_commit(total_changes_before, &conn);
4127                completed_files = completed_files.saturating_add(batch_files);
4128                ensure_cold_build_current("extraction", completed_files, total_files)?;
4129            }
4130
4131            ensure_cold_build_current("extraction", completed_files, total_files)?;
4132            let total_changes_before = conn.total_changes();
4133            let tx = conn.transaction()?;
4134            set_staged_build_phase(&tx, "indexing")?;
4135            tx.commit()?;
4136            self.record_commit(total_changes_before, &conn);
4137            phase = Some("indexing".to_string());
4138            ensure_cold_build_current("extraction", total_files, total_files)?;
4139        }
4140
4141        // Secondary indexes are intentionally created only after every extract is
4142        // durable, so pass 1 remains bulk-load shaped and pass 2 sees a complete
4143        // corpus-wide symbol/export table.
4144        note_cold_build_phase("symbol_export_index");
4145        if phase.as_deref() == Some("indexing") {
4146            ensure_cold_build_current("symbol-export-index", 0, 1)?;
4147            self.verify_writer_lease()?;
4148            let total_changes_before = conn.total_changes();
4149            let tx = conn.transaction()?;
4150            create_cold_build_secondary_indexes(&tx)?;
4151            set_staged_build_phase(&tx, "resolving")?;
4152            tx.commit()?;
4153            self.record_commit(total_changes_before, &conn);
4154            ensure_cold_build_current("symbol-export-index", 1, 1)?;
4155        }
4156
4157        note_cold_build_phase("resolution");
4158        let workspace_crate_prefixes = WorkspaceCratePrefixCache::default();
4159        let total_refs = query_count(&conn, "SELECT COUNT(*) FROM refs")? as usize;
4160        let mut resolved_refs =
4161            query_count(&conn, "SELECT COUNT(*) FROM refs WHERE status <> 'staged'")? as usize;
4162        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4163        let mut resolve_cursor = staged_u64(&conn, STAGED_RESOLVE_CURSOR)?;
4164        loop {
4165            let staged = load_staged_ref_window(&conn, resolve_cursor, resolve_window)?;
4166            let Some(last_rowid) = staged.last().map(|entry| entry.rowid) else {
4167                break;
4168            };
4169
4170            self.verify_writer_lease()?;
4171            let total_changes_before = conn.total_changes();
4172            let tx = conn.transaction()?;
4173            {
4174                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4175                let mut offset = 0;
4176                while offset < staged.len() {
4177                    let caller_file = staged[offset].raw.caller_file.clone();
4178                    let end = staged[offset..]
4179                        .iter()
4180                        .position(|entry| entry.raw.caller_file != caller_file)
4181                        .map(|relative| offset + relative)
4182                        .unwrap_or(staged.len());
4183                    let caller_extract = build_file_extract(
4184                        &self.project_root,
4185                        &self.project_root.join(&caller_file),
4186                    );
4187                    if let Ok(caller_extract) = caller_extract {
4188                        let index = DiskProjectIndex {
4189                            project_root: &self.project_root,
4190                            conn: &tx,
4191                            caller_file: &caller_file,
4192                            caller_data: &caller_extract.data,
4193                            workspace_crate_prefixes: workspace_crate_prefixes.clone(),
4194                            module_resolution_memo,
4195                            file_index_memo: RefCell::new(HashMap::new()),
4196                            module_parent_memo: RefCell::new(HashMap::new()),
4197                            memoize_resolver_indexes,
4198                        };
4199                        for staged_ref in &staged[offset..end] {
4200                            let resolved = resolve_ref(staged_ref.raw.clone(), &index)?;
4201                            insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4202                        }
4203                    } else {
4204                        for staged_ref in &staged[offset..end] {
4205                            let unresolved = unresolved_staged_ref(staged_ref.raw.clone());
4206                            insert_resolved_ref_prepared(&mut inserts, &unresolved)?;
4207                        }
4208                    }
4209                    offset = end;
4210                }
4211            }
4212            set_staged_u64(&tx, STAGED_RESOLVE_CURSOR, last_rowid)?;
4213            tx.commit()?;
4214            self.record_commit(total_changes_before, &conn);
4215            resolve_cursor = last_rowid;
4216            resolved_refs = resolved_refs.saturating_add(staged.len()).min(total_refs);
4217            ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4218        }
4219
4220        ensure_cold_build_current("resolution", resolved_refs, total_refs)?;
4221        note_cold_build_phase("publication");
4222        self.verify_writer_lease()?;
4223        let total_changes_before = conn.total_changes();
4224        let tx = conn.transaction()?;
4225        let _supplemental_edge_count =
4226            insert_method_dispatch_edges_chunked(&tx, &self.project_root, batch_files)?;
4227        set_meta_ready(&tx, true)?;
4228        set_staged_build_phase(&tx, "ready")?;
4229        tx.execute("DELETE FROM staging_file_inventory", [])?;
4230        tx.execute("DELETE FROM staging_ref_context", [])?;
4231        bump_projection_write_revision(&tx)?;
4232        tx.commit()?;
4233        self.record_commit(total_changes_before, &conn);
4234
4235        cold_build_stats_from_connection(&conn, started)
4236    }
4237
4238    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
4239        self.refresh_files_with_workspace_crate_prefix_cache(
4240            changed_files,
4241            WorkspaceCratePrefixCache::default(),
4242        )
4243    }
4244
4245    fn refresh_files_with_workspace_crate_prefix_cache(
4246        &self,
4247        changed_files: &[PathBuf],
4248        workspace_crate_prefixes: WorkspaceCratePrefixCache,
4249    ) -> Result<IncrementalStats> {
4250        let (stats, profile) = self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4251            changed_files,
4252            workspace_crate_prefixes,
4253        )?;
4254        if std::env::var_os("AFT_BENCH_REFRESH_FILES").is_some() {
4255            eprintln!("refresh_files phases: {}", profile.report());
4256        }
4257        Ok(stats)
4258    }
4259
4260    /// Run an incremental refresh and return phase timings for an offline store copy.
4261    #[doc(hidden)]
4262    pub fn refresh_files_profiled(
4263        &self,
4264        changed_files: &[PathBuf],
4265    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4266        self.refresh_files_profiled_with_workspace_crate_prefix_cache(
4267            changed_files,
4268            WorkspaceCratePrefixCache::default(),
4269        )
4270    }
4271
4272    fn refresh_files_profiled_with_workspace_crate_prefix_cache(
4273        &self,
4274        changed_files: &[PathBuf],
4275        workspace_crate_prefixes: WorkspaceCratePrefixCache,
4276    ) -> Result<(IncrementalStats, RefreshFilesProfile)> {
4277        let total_started = Instant::now();
4278        let mut profile = RefreshFilesProfile::default();
4279        self.verify_writer_lease()?;
4280        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4281        ensure_database_ready(&conn)?;
4282        let total_changes_before = conn.total_changes();
4283        let mut changed = Vec::new();
4284        let mut surface_changed = BTreeSet::new();
4285        let mut deleted = BTreeSet::new();
4286        let mut own_refresh = BTreeSet::new();
4287        let mut candidate_own_refresh = BTreeSet::new();
4288        let mut confirmed_fresh = BTreeSet::new();
4289        let mut unchanged_extracts = 0usize;
4290        let mut selected_ref_ids = BTreeSet::new();
4291        let mut selected_refs_by_caller = BTreeMap::new();
4292        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
4293        let mut fresh_metadata = BTreeMap::new();
4294
4295        for input in changed_files {
4296            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, input)
4297            {
4298                Ok(path) => path,
4299                Err(error) => {
4300                    record_path_identity_mismatch(&conn, &error)?;
4301                    return Err(error);
4302                }
4303            };
4304            changed.push(rel_path.clone());
4305            let old_row = load_file_row(&conn, &rel_path)?;
4306            if !abs_path.exists() {
4307                if old_row.is_some() && deleted.insert(rel_path.clone()) {
4308                    surface_changed.insert(rel_path.clone());
4309                    let started = Instant::now();
4310                    let dependent_refs =
4311                        ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4312                    profile.dependency_selection += started.elapsed();
4313                    record_dependent_refs(
4314                        &mut selected_ref_ids,
4315                        &mut selected_refs_by_caller,
4316                        dependent_refs,
4317                    );
4318                }
4319                continue;
4320            }
4321
4322            if let Some(row) = &old_row {
4323                match cache_freshness::verify_file(&abs_path, &row.freshness) {
4324                    FreshnessVerdict::HotFresh => {
4325                        // Content still matches the stored graph. A prior failed
4326                        // refresh may have left backend_file_state='stale' without
4327                        // changing bytes; skip the extract but still clear that
4328                        // leftover so dead-code projection can use this store.
4329                        confirmed_fresh.insert(rel_path.clone());
4330                        continue;
4331                    }
4332                    FreshnessVerdict::ContentFresh {
4333                        new_mtime,
4334                        new_size,
4335                    } => {
4336                        fresh_metadata.insert(
4337                            rel_path.clone(),
4338                            FileFreshness {
4339                                content_hash: row.freshness.content_hash,
4340                                mtime: new_mtime,
4341                                size: new_size,
4342                            },
4343                        );
4344                        continue;
4345                    }
4346                    FreshnessVerdict::Deleted => {
4347                        if deleted.insert(rel_path.clone()) {
4348                            surface_changed.insert(rel_path.clone());
4349                            let started = Instant::now();
4350                            let dependent_refs =
4351                                ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4352                            profile.dependency_selection += started.elapsed();
4353                            record_dependent_refs(
4354                                &mut selected_ref_ids,
4355                                &mut selected_refs_by_caller,
4356                                dependent_refs,
4357                            );
4358                        }
4359                        continue;
4360                    }
4361                    FreshnessVerdict::Stale => {}
4362                }
4363            }
4364
4365            let started = Instant::now();
4366            let extract = build_file_extract(&self.project_root, &abs_path)?;
4367            profile.parse += started.elapsed();
4368            let surface_is_changed = old_row
4369                .as_ref()
4370                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
4371                .unwrap_or(true);
4372            if surface_is_changed {
4373                surface_changed.insert(rel_path.clone());
4374                let started = Instant::now();
4375                let dependent_refs = ref_ids_depending_on(&conn, &self.project_root, &rel_path)?;
4376                profile.dependency_selection += started.elapsed();
4377                record_dependent_refs(
4378                    &mut selected_ref_ids,
4379                    &mut selected_refs_by_caller,
4380                    dependent_refs,
4381                );
4382            }
4383            candidate_own_refresh.insert(rel_path.clone());
4384            changed_extracts.insert(rel_path, extract);
4385        }
4386
4387        let dependency_selected_refs = selected_ref_ids.len();
4388        let mut touched_callers: BTreeSet<String> =
4389            selected_refs_by_caller.keys().cloned().collect();
4390        touched_callers.extend(candidate_own_refresh.iter().cloned());
4391
4392        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
4393        for rel_path in &touched_callers {
4394            if deleted.contains(rel_path) {
4395                continue;
4396            }
4397            if let Some(extract) = changed_extracts.get(rel_path) {
4398                caller_extracts.insert(rel_path.clone(), extract.clone());
4399                continue;
4400            }
4401            let abs_path = self.project_root.join(rel_path);
4402            if abs_path.exists() {
4403                let started = Instant::now();
4404                let extract = build_file_extract(&self.project_root, &abs_path)?;
4405                profile.dependent_parse += started.elapsed();
4406                caller_extracts.insert(rel_path.clone(), extract);
4407            }
4408        }
4409
4410        let mut projection_callers = touched_callers.clone();
4411        projection_callers.extend(deleted.iter().cloned());
4412        for file in touched_callers.iter().chain(deleted.iter()) {
4413            dead_code_projection::extend_projection_dependents(
4414                &conn,
4415                file,
4416                &mut projection_callers,
4417            )?;
4418        }
4419
4420        let tx = conn.transaction()?;
4421        for (rel_path, freshness) in fresh_metadata {
4422            update_file_fresh_metadata(
4423                &tx,
4424                &self.project_root,
4425                &rel_path,
4426                &freshness.content_hash,
4427                freshness.mtime,
4428                freshness.size,
4429            )?;
4430        }
4431        for rel_path in &confirmed_fresh {
4432            clear_stale_backend_status_for_file(&tx, &self.project_root, rel_path)?;
4433        }
4434        for rel_path in &deleted {
4435            let started = Instant::now();
4436            delete_file_rows(&tx, rel_path)?;
4437            clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4438            profile.row_deletes += started.elapsed();
4439        }
4440
4441        // Already-fresh inputs have no callers to resolve. Backend freshness
4442        // writes do not change projection inputs, so only deletions invalidate
4443        // the retained snapshot on this path.
4444        if caller_extracts.is_empty() {
4445            let wrote_rows = tx.total_changes() != total_changes_before;
4446            if !deleted.is_empty() {
4447                dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4448            }
4449            let started = Instant::now();
4450            commit_incremental_if_current(tx)?;
4451            if wrote_rows {
4452                self.record_commit(total_changes_before, &conn);
4453            }
4454            profile.commit += started.elapsed();
4455            profile.total = total_started.elapsed();
4456            return Ok((
4457                IncrementalStats {
4458                    changed_files: changed,
4459                    surface_changed: surface_changed.into_iter().collect(),
4460                    deleted_files: deleted.into_iter().collect(),
4461                    dependency_selected_refs,
4462                    refreshed_own_files: 0,
4463                    unchanged_extract_files: 0,
4464                },
4465                profile,
4466            ));
4467        }
4468
4469        let started = Instant::now();
4470        profile.index_loads += 1;
4471        let index = ProjectIndex::from_db_and_callers(
4472            &tx,
4473            &self.project_root,
4474            &caller_extracts,
4475            workspace_crate_prefixes,
4476        )?;
4477        profile.index_load += started.elapsed();
4478
4479        let workspace_root = self.project_root.display().to_string();
4480        {
4481            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
4482            for rel_path in &candidate_own_refresh {
4483                let Some(extract) = changed_extracts.get(rel_path) else {
4484                    continue;
4485                };
4486                if stored_extract_matches(&tx, rel_path, extract, &index)? {
4487                    unchanged_extracts += 1;
4488                    update_file_fresh_metadata(
4489                        &tx,
4490                        &self.project_root,
4491                        rel_path,
4492                        &extract.freshness.content_hash,
4493                        extract.freshness.mtime,
4494                        extract.freshness.size,
4495                    )?;
4496                    continue;
4497                }
4498
4499                own_refresh.insert(rel_path.clone());
4500                let started = Instant::now();
4501                delete_file_rows(&tx, rel_path)?;
4502                clear_backend_state_for_file(&tx, &self.project_root, rel_path)?;
4503                profile.row_deletes += started.elapsed();
4504                let started = Instant::now();
4505                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4506                profile.row_inserts += started.elapsed();
4507            }
4508
4509            let dependency_callers = touched_callers
4510                .iter()
4511                .filter(|rel_path| {
4512                    !deleted.contains(*rel_path) && !candidate_own_refresh.contains(*rel_path)
4513                })
4514                .cloned()
4515                .collect::<Vec<_>>();
4516            for rel_path in dependency_callers {
4517                let Some(extract) = caller_extracts.get(&rel_path) else {
4518                    continue;
4519                };
4520                if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
4521                    continue;
4522                }
4523
4524                own_refresh.insert(rel_path.clone());
4525                let started = Instant::now();
4526                delete_file_rows(&tx, &rel_path)?;
4527                clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
4528                profile.row_deletes += started.elapsed();
4529                let started = Instant::now();
4530                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
4531                profile.row_inserts += started.elapsed();
4532            }
4533            let started = Instant::now();
4534            for rel_path in &touched_callers {
4535                if deleted.contains(rel_path) {
4536                    continue;
4537                }
4538                let Some(extract) = caller_extracts.get(rel_path) else {
4539                    continue;
4540                };
4541                if own_refresh.contains(rel_path) {
4542                    delete_refs_for_caller(&tx, rel_path)?;
4543                    for raw_ref in &extract.raw_refs {
4544                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4545                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4546                    }
4547                    continue;
4548                }
4549
4550                let selected_for_caller = selected_refs_by_caller
4551                    .get(rel_path)
4552                    .cloned()
4553                    .unwrap_or_default();
4554                delete_ref_ids(&tx, &selected_for_caller)?;
4555                for raw_ref in &extract.raw_refs {
4556                    if selected_for_caller.contains(&raw_ref.ref_id) {
4557                        let resolved = resolve_ref(raw_ref.clone(), &index)?;
4558                        insert_resolved_ref_prepared(&mut inserts, &resolved)?;
4559                    }
4560                }
4561            }
4562            profile.ref_resolution += started.elapsed();
4563        }
4564
4565        let started = Instant::now();
4566        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
4567        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
4568        profile.method_dispatch += started.elapsed();
4569
4570        // Freshness metadata is not an input to the projection. Only changed
4571        // graph rows need a new revision and a corresponding caller delta.
4572        if !own_refresh.is_empty() || !selected_ref_ids.is_empty() || !deleted.is_empty() {
4573            dead_code_projection::record_projection_delta(&tx, &projection_callers)?;
4574        }
4575        let started = Instant::now();
4576        commit_incremental_if_current(tx)?;
4577        self.record_commit(total_changes_before, &conn);
4578        profile.commit += started.elapsed();
4579        profile.total = total_started.elapsed();
4580        Ok((
4581            IncrementalStats {
4582                changed_files: changed,
4583                surface_changed: surface_changed.into_iter().collect(),
4584                deleted_files: deleted.into_iter().collect(),
4585                dependency_selected_refs,
4586                refreshed_own_files: own_refresh.len(),
4587                unchanged_extract_files: unchanged_extracts,
4588            },
4589            profile,
4590        ))
4591    }
4592
4593    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
4594        self.cold_build(current_files)
4595    }
4596
4597    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
4598        self.verify_writer_lease()?;
4599        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
4600        let total_changes_before = conn.total_changes();
4601        let tx = conn.transaction()?;
4602        let mut marked = Vec::new();
4603        for path in files {
4604            let (abs_path, rel_path) = match normalize_project_file_path(&self.project_root, path) {
4605                Ok(path) => path,
4606                Err(error) => {
4607                    drop(tx);
4608                    record_path_identity_mismatch(&conn, &error)?;
4609                    return Err(error);
4610                }
4611            };
4612            let freshness = cache_freshness::collect(&abs_path).ok();
4613            mark_backend_state(
4614                &tx,
4615                &self.project_root,
4616                &rel_path,
4617                freshness.as_ref().map(|freshness| &freshness.content_hash),
4618                "stale",
4619            )?;
4620            marked.push(rel_path);
4621        }
4622        // A stale marker blocks projection until refresh but does not mutate graph
4623        // rows. Only a refresh that changes graph rows advances the revision and
4624        // writes the matching changed-file journal entry.
4625        tx.commit()?;
4626        self.record_commit(total_changes_before, &conn);
4627        marked.sort();
4628        marked.dedup();
4629        Ok(marked)
4630    }
4631
4632    pub fn stale_files(&self) -> Result<Vec<String>> {
4633        self.refresh_read_marker()?;
4634        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4635        let mut stmt = conn.prepare(
4636            "SELECT DISTINCT file_path FROM backend_file_state
4637             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
4638             ORDER BY file_path",
4639        )?;
4640        let rows = stmt.query_map(
4641            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
4642            |row| row.get::<_, String>(0),
4643        )?;
4644        rows.collect::<std::result::Result<Vec<_>, _>>()
4645            .map_err(Into::into)
4646    }
4647
4648    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
4649        self.refresh_read_marker()?;
4650        let rel_path = relative_path(
4651            &self.project_root,
4652            &normalize_file_path(&self.project_root, file)?,
4653        );
4654        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4655        conn.query_row(
4656            "SELECT status FROM backend_file_state
4657             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
4658             ORDER BY updated_at DESC LIMIT 1",
4659            params![
4660                BACKEND_TREESITTER,
4661                self.project_root.display().to_string(),
4662                rel_path
4663            ],
4664            |row| row.get(0),
4665        )
4666        .optional()
4667        .map_err(Into::into)
4668    }
4669
4670    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
4671        self.refresh_read_marker()?;
4672        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4673        self.ensure_ready(&conn)?;
4674        edge_snapshot_with_conn(&conn)
4675    }
4676
4677    pub fn indexed_file_count(&self) -> Result<usize> {
4678        self.refresh_read_marker()?;
4679        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4680        self.ensure_ready(&conn)?;
4681        indexed_file_count(&conn)
4682    }
4683
4684    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
4685        self.refresh_read_marker()?;
4686        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4687        let rel_path = relative_path(&self.project_root, &abs_path);
4688        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4689        self.ensure_ready(&conn)?;
4690        resolve_node_for_rel(&conn, &rel_path, symbol)
4691    }
4692
4693    /// Return all positional nodes matching a legacy symbol query in a file.
4694    ///
4695    /// Consumers that need legacy compatibility can collapse these by
4696    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
4697    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
4698        self.refresh_read_marker()?;
4699        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4700        let rel_path = relative_path(&self.project_root, &abs_path);
4701        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4702        self.ensure_ready(&conn)?;
4703        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
4704    }
4705
4706    /// Return all positional nodes matching a symbol query anywhere in the store.
4707    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
4708        self.refresh_read_marker()?;
4709        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4710        self.ensure_ready(&conn)?;
4711        nodes_matching_symbol(&conn, symbol)
4712    }
4713
4714    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
4715    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
4716        self.refresh_read_marker()?;
4717        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
4718        let rel_path = relative_path(&self.project_root, &abs_path);
4719        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4720        self.ensure_ready(&conn)?;
4721        direct_callers_for_tuple(&conn, &rel_path, symbol)
4722    }
4723
4724    /// Fetch direct callers for a reverse-traversal frontier in bounded batches.
4725    pub fn direct_callers_for_symbols(
4726        &self,
4727        targets: &[(String, String)],
4728    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4729        if targets.is_empty() {
4730            return Ok(HashMap::new());
4731        }
4732        self.refresh_read_marker()?;
4733        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4734        self.ensure_ready(&conn)?;
4735        direct_callers_for_tuples(&conn, targets)
4736    }
4737
4738    /// Count distinct direct call sites for store-relative target tuples in bounded batches.
4739    pub fn direct_caller_counts_of(
4740        &self,
4741        targets: &[(String, String)],
4742    ) -> Result<HashMap<(String, String), usize>> {
4743        if targets.is_empty() {
4744            return Ok(HashMap::new());
4745        }
4746        self.refresh_read_marker()?;
4747        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4748        self.ensure_ready(&conn)?;
4749        direct_caller_counts_for_tuples(&conn, targets)
4750    }
4751
4752    pub fn callers_of(
4753        &self,
4754        file_rel: &Path,
4755        symbol: &str,
4756        depth: usize,
4757    ) -> Result<StoreCallersResult> {
4758        let target = self.node_for(file_rel, symbol)?;
4759        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4760        self.ensure_ready(&conn)?;
4761        let effective_depth = depth.max(1);
4762        let mut visited = HashSet::new();
4763        let mut callers = Vec::new();
4764        let mut depth_limited = false;
4765        let mut truncated = 0usize;
4766        collect_callers_recursive(
4767            &conn,
4768            &target.file,
4769            &target.symbol,
4770            effective_depth,
4771            0,
4772            &mut visited,
4773            &mut callers,
4774            &mut depth_limited,
4775            &mut truncated,
4776        )?;
4777        Ok(StoreCallersResult {
4778            target,
4779            callers,
4780            scanned_files: indexed_file_count(&conn)?,
4781            depth_limited,
4782            truncated,
4783        })
4784    }
4785
4786    pub fn impact_of(
4787        &self,
4788        file_rel: &Path,
4789        symbol: &str,
4790        depth: usize,
4791    ) -> Result<StoreImpactResult> {
4792        let callers = self.callers_of(file_rel, symbol, depth)?;
4793        let target_parameters = callers
4794            .target
4795            .signature
4796            .as_deref()
4797            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
4798            .unwrap_or_default();
4799        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
4800        for site in &callers.callers {
4801            source_lines_by_file
4802                .entry(site.caller.file.clone())
4803                .or_insert_with(|| {
4804                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
4805                });
4806        }
4807        let enriched = callers
4808            .callers
4809            .iter()
4810            .map(|site| StoreImpactCaller {
4811                site: site.clone(),
4812                signature: site.caller.signature.clone(),
4813                is_entry_point: site.caller.is_entry_point,
4814                call_expression: source_lines_by_file
4815                    .get(&site.caller.file)
4816                    .and_then(|lines| lines.as_ref())
4817                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
4818                    .cloned(),
4819                parameters: site
4820                    .caller
4821                    .signature
4822                    .as_deref()
4823                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
4824                    .unwrap_or_default(),
4825            })
4826            .collect();
4827        Ok(StoreImpactResult {
4828            target: callers.target,
4829            parameters: target_parameters,
4830            callers: enriched,
4831            depth_limited: callers.depth_limited,
4832            truncated: callers.truncated,
4833        })
4834    }
4835
4836    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4837        self.refresh_read_marker()?;
4838        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4839        self.ensure_ready(&conn)?;
4840        outgoing_calls_for_node(&conn, node)
4841    }
4842
4843    /// Fetch outgoing calls for a BFS frontier without reopening the store per symbol or edge.
4844    pub fn outgoing_calls_for_symbols(
4845        &self,
4846        sources: &[(String, String)],
4847    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
4848        if sources.is_empty() {
4849            return Ok(HashMap::new());
4850        }
4851        self.refresh_read_marker()?;
4852        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4853        self.ensure_ready(&conn)?;
4854        outgoing_calls_for_symbol_tuples(&conn, sources)
4855    }
4856
4857    /// Return resolved direct self-call refs suppressed from the general edge table.
4858    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
4859        self.refresh_read_marker()?;
4860        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4861        self.ensure_ready(&conn)?;
4862        resolved_self_calls_for_node(&conn, node)
4863    }
4864
4865    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
4866        self.refresh_read_marker()?;
4867        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4868        self.ensure_ready(&conn)?;
4869        unresolved_calls_for_node(&conn, node)
4870    }
4871
4872    pub fn call_tree(
4873        &self,
4874        file_rel: &Path,
4875        symbol: &str,
4876        max_depth: usize,
4877    ) -> Result<callgraph::CallTreeNode> {
4878        let node = self.node_for(file_rel, symbol)?;
4879        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4880        self.ensure_ready(&conn)?;
4881        let mut visited = HashSet::new();
4882        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
4883    }
4884
4885    pub fn trace_to(
4886        &self,
4887        file_rel: &Path,
4888        symbol: &str,
4889        max_depth: usize,
4890    ) -> Result<callgraph::TraceToResult> {
4891        let target = self.node_for(file_rel, symbol)?;
4892        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
4893        self.ensure_ready(&conn)?;
4894        let effective_max = if max_depth == 0 { 10 } else { max_depth };
4895
4896        #[derive(Clone)]
4897        struct PathElem {
4898            node: StoreNode,
4899        }
4900
4901        let initial = vec![PathElem {
4902            node: target.clone(),
4903        }];
4904        let mut complete_paths = Vec::new();
4905        if target.is_entry_point {
4906            complete_paths.push(initial.clone());
4907        }
4908
4909        let mut queue = vec![(initial, 0usize)];
4910        let mut max_depth_reached = false;
4911        let mut truncated_paths = 0usize;
4912
4913        while let Some((path, depth)) = queue.pop() {
4914            if depth >= effective_max {
4915                max_depth_reached = true;
4916                continue;
4917            }
4918            let Some(current) = path.last() else {
4919                continue;
4920            };
4921            let callers =
4922                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
4923            if callers.is_empty() {
4924                if path.len() > 1 {
4925                    truncated_paths += 1;
4926                }
4927                continue;
4928            }
4929
4930            let mut has_new_path = false;
4931            for site in callers {
4932                if path.iter().any(|elem| {
4933                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
4934                }) {
4935                    continue;
4936                }
4937                has_new_path = true;
4938                let mut new_path = path.clone();
4939                new_path.push(PathElem {
4940                    node: site.caller.clone(),
4941                });
4942                if site.caller.is_entry_point {
4943                    complete_paths.push(new_path.clone());
4944                }
4945                queue.push((new_path, depth + 1));
4946            }
4947            if !has_new_path && path.len() > 1 {
4948                truncated_paths += 1;
4949            }
4950        }
4951
4952        let mut paths: Vec<callgraph::TracePath> = complete_paths
4953            .into_iter()
4954            .map(|mut elems| {
4955                elems.reverse();
4956                let hops = elems
4957                    .iter()
4958                    .enumerate()
4959                    .map(|(index, elem)| callgraph::TraceHop {
4960                        symbol: elem.node.symbol.clone(),
4961                        file: elem.node.file.clone(),
4962                        line: elem.node.line,
4963                        signature: elem.node.signature.clone(),
4964                        is_entry_point: index == 0 && elem.node.is_entry_point,
4965                    })
4966                    .collect();
4967                callgraph::TracePath { hops }
4968            })
4969            .collect();
4970        paths.sort_by(|left, right| {
4971            let left_entry = left
4972                .hops
4973                .first()
4974                .map(|hop| hop.symbol.as_str())
4975                .unwrap_or("");
4976            let right_entry = right
4977                .hops
4978                .first()
4979                .map(|hop| hop.symbol.as_str())
4980                .unwrap_or("");
4981            left_entry
4982                .cmp(right_entry)
4983                .then(left.hops.len().cmp(&right.hops.len()))
4984        });
4985        let entry_points_found = paths
4986            .iter()
4987            .filter_map(|path| path.hops.first())
4988            .filter(|hop| hop.is_entry_point)
4989            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
4990            .collect::<HashSet<_>>()
4991            .len();
4992
4993        Ok(callgraph::TraceToResult {
4994            target_symbol: target.symbol,
4995            target_file: target.file,
4996            total_paths: paths.len(),
4997            paths,
4998            entry_points_found,
4999            max_depth_reached,
5000            truncated_paths,
5001        })
5002    }
5003
5004    pub fn trace_to_symbol_candidates(
5005        &self,
5006        to_symbol: &str,
5007    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
5008        self.refresh_read_marker()?;
5009        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5010        self.ensure_ready(&conn)?;
5011        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
5012        for node in nodes_matching_symbol(&conn, to_symbol)? {
5013            candidates_by_file
5014                .entry(node.file)
5015                .and_modify(|line| *line = (*line).min(node.line))
5016                .or_insert(node.line);
5017        }
5018        let mut candidates: Vec<_> = candidates_by_file
5019            .into_iter()
5020            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
5021            .collect();
5022        candidates
5023            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
5024        Ok(candidates)
5025    }
5026
5027    pub fn trace_to_symbol(
5028        &self,
5029        file_rel: &Path,
5030        symbol: &str,
5031        to_symbol: &str,
5032        to_file: Option<&Path>,
5033        max_depth: usize,
5034    ) -> Result<callgraph::TraceToSymbolResult> {
5035        let origin = self.node_for(file_rel, symbol)?;
5036        let target_file = to_file
5037            .map(|path| normalize_file_path(&self.project_root, path))
5038            .transpose()?
5039            .map(|path| relative_path(&self.project_root, &path));
5040        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
5041        self.ensure_ready(&conn)?;
5042        let effective_max = if max_depth == 0 {
5043            10
5044        } else {
5045            max_depth.min(16)
5046        };
5047
5048        let start_hop = trace_to_symbol_hop(&origin);
5049        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
5050            return Ok(callgraph::TraceToSymbolResult {
5051                path: Some(vec![start_hop]),
5052                complete: true,
5053                reason: None,
5054            });
5055        }
5056
5057        let mut queue = VecDeque::new();
5058        queue.push_back((origin.clone(), vec![start_hop], 0usize));
5059        let mut visited = HashSet::new();
5060        visited.insert((origin.file.clone(), origin.symbol.clone()));
5061        let mut max_depth_exhausted = false;
5062
5063        while let Some((current, path, depth)) = queue.pop_front() {
5064            let callees = outgoing_calls_for_node(&conn, &current)?
5065                .into_iter()
5066                .filter_map(|site| site.target)
5067                .collect::<Vec<_>>();
5068
5069            if depth >= effective_max {
5070                if callees
5071                    .iter()
5072                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
5073                {
5074                    max_depth_exhausted = true;
5075                }
5076                continue;
5077            }
5078
5079            for callee in callees {
5080                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
5081                    continue;
5082                }
5083                let mut next_path = path.clone();
5084                next_path.push(trace_to_symbol_hop(&callee));
5085                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
5086                    return Ok(callgraph::TraceToSymbolResult {
5087                        path: Some(next_path),
5088                        complete: true,
5089                        reason: None,
5090                    });
5091                }
5092                queue.push_back((callee, next_path, depth + 1));
5093            }
5094        }
5095
5096        if max_depth_exhausted {
5097            Ok(callgraph::TraceToSymbolResult {
5098                path: None,
5099                complete: false,
5100                reason: Some("max_depth_exhausted".to_string()),
5101            })
5102        } else {
5103            Ok(callgraph::TraceToSymbolResult {
5104                path: None,
5105                complete: true,
5106                reason: Some("no_path_found".to_string()),
5107            })
5108        }
5109    }
5110}
5111
5112impl ReadonlyCallGraphStore {
5113    pub(crate) fn open_manifest_view(
5114        project_root: PathBuf,
5115        family: String,
5116        view_dir: PathBuf,
5117        generation: &str,
5118        pin: Option<Arc<crate::pins::QueryPin>>,
5119    ) -> Result<Self> {
5120        let generation_path = view_dir.join(format!("derived-{generation}.sqlite"));
5121        // Older publications used one checkout-wide database. Keep them readable
5122        // until the first generation-owned publication replaces their handle.
5123        let sqlite_path = if generation_path.is_file() {
5124            generation_path
5125        } else {
5126            view_dir.join("derived.sqlite")
5127        };
5128        let conn = open_readonly_connection(&sqlite_path)?;
5129        ensure_database_ready(&conn)?;
5130        let mut inner = CallGraphStore::from_connection(
5131            project_root,
5132            family,
5133            sqlite_path,
5134            view_dir,
5135            false,
5136            None,
5137            None,
5138            None,
5139            conn,
5140        );
5141        inner.manifest_view = true;
5142        inner.database_ready.store(true, AtomicOrdering::Release);
5143        Ok(Self {
5144            inner,
5145            _view_pin: pin,
5146        })
5147    }
5148
5149    pub fn reader_kind(&self) -> &'static str {
5150        if self.inner.manifest_view {
5151            "view"
5152        } else {
5153            "legacy"
5154        }
5155    }
5156
5157    fn from_inner(inner: CallGraphStore) -> Self {
5158        Self {
5159            inner,
5160            _view_pin: None,
5161        }
5162    }
5163
5164    pub fn project_root(&self) -> &Path {
5165        self.inner.project_root()
5166    }
5167
5168    pub fn project_key(&self) -> &str {
5169        self.inner.project_key()
5170    }
5171
5172    pub fn sqlite_path(&self) -> &Path {
5173        self.inner.sqlite_path()
5174    }
5175
5176    pub fn stale_files(&self) -> Result<Vec<String>> {
5177        self.inner.stale_files()
5178    }
5179
5180    pub(crate) fn projection_generation(&self) -> Option<&str> {
5181        self.inner.projection_generation()
5182    }
5183
5184    pub(crate) fn projection_write_revision(&self) -> Result<Option<u64>> {
5185        self.inner.projection_write_revision()
5186    }
5187
5188    /// Report the open generation handle. SQLite-owned allocations are measured
5189    /// once by the process-wide SQLite allocator counters.
5190    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
5191        crate::memory::MemoryEstimate::partial(0).count("open_generation_handles", 1)
5192    }
5193
5194    /// Whether this reader is temporarily serving a legacy harness partition.
5195    pub fn is_legacy_fallback(&self) -> bool {
5196        self.inner.is_legacy_fallback()
5197    }
5198
5199    pub fn is_current(&self) -> bool {
5200        self.inner.is_current()
5201    }
5202
5203    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5204        self.inner.edge_snapshot()
5205    }
5206
5207    pub fn indexed_file_count(&self) -> Result<usize> {
5208        self.inner.indexed_file_count()
5209    }
5210
5211    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5212        self.inner.node_for(file_rel, symbol)
5213    }
5214
5215    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5216        self.inner.nodes_for(file_rel, symbol)
5217    }
5218
5219    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5220        self.inner.nodes_matching(symbol)
5221    }
5222
5223    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5224        self.inner.direct_callers_of(file_rel, symbol)
5225    }
5226
5227    pub fn direct_callers_for_symbols(
5228        &self,
5229        targets: &[(String, String)],
5230    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5231        self.inner.direct_callers_for_symbols(targets)
5232    }
5233
5234    pub fn direct_caller_counts_of(
5235        &self,
5236        targets: &[(String, String)],
5237    ) -> Result<HashMap<(String, String), usize>> {
5238        self.inner.direct_caller_counts_of(targets)
5239    }
5240
5241    pub fn callers_of(
5242        &self,
5243        file_rel: &Path,
5244        symbol: &str,
5245        depth: usize,
5246    ) -> Result<StoreCallersResult> {
5247        self.inner.callers_of(file_rel, symbol, depth)
5248    }
5249
5250    pub fn impact_of(
5251        &self,
5252        file_rel: &Path,
5253        symbol: &str,
5254        depth: usize,
5255    ) -> Result<StoreImpactResult> {
5256        self.inner.impact_of(file_rel, symbol, depth)
5257    }
5258
5259    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5260        self.inner.outgoing_calls_of(node)
5261    }
5262
5263    pub fn outgoing_calls_for_symbols(
5264        &self,
5265        sources: &[(String, String)],
5266    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5267        self.inner.outgoing_calls_for_symbols(sources)
5268    }
5269
5270    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5271        self.inner.resolved_self_calls_of(node)
5272    }
5273
5274    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5275        self.inner.unresolved_calls_of(node)
5276    }
5277
5278    pub fn call_tree(
5279        &self,
5280        file_rel: &Path,
5281        symbol: &str,
5282        depth: usize,
5283    ) -> Result<callgraph::CallTreeNode> {
5284        self.inner.call_tree(file_rel, symbol, depth)
5285    }
5286
5287    pub fn trace_to(
5288        &self,
5289        file_rel: &Path,
5290        symbol: &str,
5291        max_depth: usize,
5292    ) -> Result<callgraph::TraceToResult> {
5293        self.inner.trace_to(file_rel, symbol, max_depth)
5294    }
5295
5296    pub fn trace_to_symbol_candidates(
5297        &self,
5298        to_symbol: &str,
5299    ) -> Result<Vec<TraceToSymbolCandidate>> {
5300        self.inner.trace_to_symbol_candidates(to_symbol)
5301    }
5302
5303    pub fn trace_to_symbol(
5304        &self,
5305        file_rel: &Path,
5306        symbol: &str,
5307        to_symbol: &str,
5308        to_file: Option<&Path>,
5309        max_depth: usize,
5310    ) -> Result<callgraph::TraceToSymbolResult> {
5311        self.inner
5312            .trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5313    }
5314}
5315
5316impl CallGraphRead for CallGraphStore {
5317    fn project_root(&self) -> &Path {
5318        CallGraphStore::project_root(self)
5319    }
5320    fn project_key(&self) -> &str {
5321        CallGraphStore::project_key(self)
5322    }
5323    fn sqlite_path(&self) -> &Path {
5324        CallGraphStore::sqlite_path(self)
5325    }
5326    fn is_current(&self) -> bool {
5327        CallGraphStore::is_current(self)
5328    }
5329    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5330        CallGraphStore::edge_snapshot(self)
5331    }
5332    fn indexed_file_count(&self) -> Result<usize> {
5333        CallGraphStore::indexed_file_count(self)
5334    }
5335    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5336        CallGraphStore::node_for(self, file_rel, symbol)
5337    }
5338    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5339        CallGraphStore::nodes_for(self, file_rel, symbol)
5340    }
5341    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5342        CallGraphStore::nodes_matching(self, symbol)
5343    }
5344    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5345        CallGraphStore::direct_callers_of(self, file_rel, symbol)
5346    }
5347    fn direct_callers_for_symbols(
5348        &self,
5349        targets: &[(String, String)],
5350    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5351        CallGraphStore::direct_callers_for_symbols(self, targets)
5352    }
5353    fn direct_caller_counts_of(
5354        &self,
5355        targets: &[(String, String)],
5356    ) -> Result<HashMap<(String, String), usize>> {
5357        CallGraphStore::direct_caller_counts_of(self, targets)
5358    }
5359    fn callers_of(
5360        &self,
5361        file_rel: &Path,
5362        symbol: &str,
5363        depth: usize,
5364    ) -> Result<StoreCallersResult> {
5365        CallGraphStore::callers_of(self, file_rel, symbol, depth)
5366    }
5367    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5368        CallGraphStore::impact_of(self, file_rel, symbol, depth)
5369    }
5370    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5371        CallGraphStore::outgoing_calls_of(self, node)
5372    }
5373    fn outgoing_calls_for_symbols(
5374        &self,
5375        sources: &[(String, String)],
5376    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5377        CallGraphStore::outgoing_calls_for_symbols(self, sources)
5378    }
5379    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5380        CallGraphStore::resolved_self_calls_of(self, node)
5381    }
5382    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5383        CallGraphStore::unresolved_calls_of(self, node)
5384    }
5385    fn call_tree(
5386        &self,
5387        file_rel: &Path,
5388        symbol: &str,
5389        depth: usize,
5390    ) -> Result<callgraph::CallTreeNode> {
5391        CallGraphStore::call_tree(self, file_rel, symbol, depth)
5392    }
5393    fn trace_to(
5394        &self,
5395        file_rel: &Path,
5396        symbol: &str,
5397        max_depth: usize,
5398    ) -> Result<callgraph::TraceToResult> {
5399        CallGraphStore::trace_to(self, file_rel, symbol, max_depth)
5400    }
5401    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5402        CallGraphStore::trace_to_symbol_candidates(self, to_symbol)
5403    }
5404    fn trace_to_symbol(
5405        &self,
5406        file_rel: &Path,
5407        symbol: &str,
5408        to_symbol: &str,
5409        to_file: Option<&Path>,
5410        max_depth: usize,
5411    ) -> Result<callgraph::TraceToSymbolResult> {
5412        CallGraphStore::trace_to_symbol(self, file_rel, symbol, to_symbol, to_file, max_depth)
5413    }
5414}
5415
5416impl<T: CallGraphRead + ?Sized> CallGraphRead for Arc<T> {
5417    fn project_root(&self) -> &Path {
5418        (**self).project_root()
5419    }
5420    fn project_key(&self) -> &str {
5421        (**self).project_key()
5422    }
5423    fn sqlite_path(&self) -> &Path {
5424        (**self).sqlite_path()
5425    }
5426    fn is_current(&self) -> bool {
5427        (**self).is_current()
5428    }
5429    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5430        (**self).edge_snapshot()
5431    }
5432    fn indexed_file_count(&self) -> Result<usize> {
5433        (**self).indexed_file_count()
5434    }
5435    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5436        (**self).node_for(file_rel, symbol)
5437    }
5438    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5439        (**self).nodes_for(file_rel, symbol)
5440    }
5441    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5442        (**self).nodes_matching(symbol)
5443    }
5444    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5445        (**self).direct_callers_of(file_rel, symbol)
5446    }
5447    fn direct_callers_for_symbols(
5448        &self,
5449        targets: &[(String, String)],
5450    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5451        (**self).direct_callers_for_symbols(targets)
5452    }
5453    fn direct_caller_counts_of(
5454        &self,
5455        targets: &[(String, String)],
5456    ) -> Result<HashMap<(String, String), usize>> {
5457        (**self).direct_caller_counts_of(targets)
5458    }
5459    fn callers_of(
5460        &self,
5461        file_rel: &Path,
5462        symbol: &str,
5463        depth: usize,
5464    ) -> Result<StoreCallersResult> {
5465        (**self).callers_of(file_rel, symbol, depth)
5466    }
5467    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5468        (**self).impact_of(file_rel, symbol, depth)
5469    }
5470    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5471        (**self).outgoing_calls_of(node)
5472    }
5473    fn outgoing_calls_for_symbols(
5474        &self,
5475        sources: &[(String, String)],
5476    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5477        (**self).outgoing_calls_for_symbols(sources)
5478    }
5479    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5480        (**self).resolved_self_calls_of(node)
5481    }
5482    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5483        (**self).unresolved_calls_of(node)
5484    }
5485    fn call_tree(
5486        &self,
5487        file_rel: &Path,
5488        symbol: &str,
5489        depth: usize,
5490    ) -> Result<callgraph::CallTreeNode> {
5491        (**self).call_tree(file_rel, symbol, depth)
5492    }
5493    fn trace_to(
5494        &self,
5495        file_rel: &Path,
5496        symbol: &str,
5497        max_depth: usize,
5498    ) -> Result<callgraph::TraceToResult> {
5499        (**self).trace_to(file_rel, symbol, max_depth)
5500    }
5501    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5502        (**self).trace_to_symbol_candidates(to_symbol)
5503    }
5504    fn trace_to_symbol(
5505        &self,
5506        file_rel: &Path,
5507        symbol: &str,
5508        to_symbol: &str,
5509        to_file: Option<&Path>,
5510        max_depth: usize,
5511    ) -> Result<callgraph::TraceToSymbolResult> {
5512        (**self).trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5513    }
5514}
5515
5516impl CallGraphRead for ReadonlyCallGraphStore {
5517    fn project_root(&self) -> &Path {
5518        self.project_root()
5519    }
5520    fn project_key(&self) -> &str {
5521        self.project_key()
5522    }
5523    fn sqlite_path(&self) -> &Path {
5524        self.sqlite_path()
5525    }
5526    fn is_current(&self) -> bool {
5527        self.is_current()
5528    }
5529    fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
5530        self.edge_snapshot()
5531    }
5532    fn indexed_file_count(&self) -> Result<usize> {
5533        self.indexed_file_count()
5534    }
5535    fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
5536        self.node_for(file_rel, symbol)
5537    }
5538    fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
5539        self.nodes_for(file_rel, symbol)
5540    }
5541    fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
5542        self.nodes_matching(symbol)
5543    }
5544    fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
5545        self.direct_callers_of(file_rel, symbol)
5546    }
5547    fn direct_callers_for_symbols(
5548        &self,
5549        targets: &[(String, String)],
5550    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5551        self.direct_callers_for_symbols(targets)
5552    }
5553    fn direct_caller_counts_of(
5554        &self,
5555        targets: &[(String, String)],
5556    ) -> Result<HashMap<(String, String), usize>> {
5557        self.direct_caller_counts_of(targets)
5558    }
5559    fn callers_of(
5560        &self,
5561        file_rel: &Path,
5562        symbol: &str,
5563        depth: usize,
5564    ) -> Result<StoreCallersResult> {
5565        self.callers_of(file_rel, symbol, depth)
5566    }
5567    fn impact_of(&self, file_rel: &Path, symbol: &str, depth: usize) -> Result<StoreImpactResult> {
5568        self.impact_of(file_rel, symbol, depth)
5569    }
5570    fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5571        self.outgoing_calls_of(node)
5572    }
5573    fn outgoing_calls_for_symbols(
5574        &self,
5575        sources: &[(String, String)],
5576    ) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5577        self.outgoing_calls_for_symbols(sources)
5578    }
5579    fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
5580        self.resolved_self_calls_of(node)
5581    }
5582    fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
5583        self.unresolved_calls_of(node)
5584    }
5585    fn call_tree(
5586        &self,
5587        file_rel: &Path,
5588        symbol: &str,
5589        depth: usize,
5590    ) -> Result<callgraph::CallTreeNode> {
5591        self.call_tree(file_rel, symbol, depth)
5592    }
5593    fn trace_to(
5594        &self,
5595        file_rel: &Path,
5596        symbol: &str,
5597        max_depth: usize,
5598    ) -> Result<callgraph::TraceToResult> {
5599        self.trace_to(file_rel, symbol, max_depth)
5600    }
5601    fn trace_to_symbol_candidates(&self, to_symbol: &str) -> Result<Vec<TraceToSymbolCandidate>> {
5602        self.trace_to_symbol_candidates(to_symbol)
5603    }
5604    fn trace_to_symbol(
5605        &self,
5606        file_rel: &Path,
5607        symbol: &str,
5608        to_symbol: &str,
5609        to_file: Option<&Path>,
5610        max_depth: usize,
5611    ) -> Result<callgraph::TraceToSymbolResult> {
5612        self.trace_to_symbol(file_rel, symbol, to_symbol, to_file, max_depth)
5613    }
5614}
5615
5616fn indexed_file_count(conn: &Connection) -> Result<usize> {
5617    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
5618    Ok(count.max(0) as usize)
5619}
5620
5621fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
5622    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
5623    match candidates.as_slice() {
5624        [candidate] => Ok(candidate.clone()),
5625        [] => Err(AftError::SymbolNotFound {
5626            name: symbol.to_string(),
5627            file: rel_path.to_string(),
5628        }
5629        .into()),
5630        _ => Err(AftError::AmbiguousSymbol {
5631            name: symbol.to_string(),
5632            candidates: candidates
5633                .iter()
5634                .map(|candidate| candidate.symbol.clone())
5635                .collect(),
5636        }
5637        .into()),
5638    }
5639}
5640
5641fn nodes_for_file_matching_symbol(
5642    conn: &Connection,
5643    rel_path: &str,
5644    symbol: &str,
5645) -> Result<Vec<StoreNode>> {
5646    let qualified_query = symbol.contains("::");
5647    let sql = if qualified_query {
5648        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5649                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5650         FROM nodes n JOIN files f ON f.path = n.file_path
5651         WHERE n.file_path = ?1 AND n.scoped_name = ?2
5652         ORDER BY n.scoped_name, n.start_line, n.start_col"
5653    } else {
5654        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5655                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5656         FROM nodes n JOIN files f ON f.path = n.file_path
5657         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
5658         ORDER BY n.scoped_name, n.start_line, n.start_col"
5659    };
5660    let mut stmt = conn.prepare(sql)?;
5661    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
5662    rows.collect::<std::result::Result<Vec<_>, _>>()
5663        .map_err(Into::into)
5664}
5665
5666fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
5667    let qualified_query = symbol.contains("::");
5668    let sql = if qualified_query {
5669        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5670                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5671         FROM nodes n JOIN files f ON f.path = n.file_path
5672         WHERE n.scoped_name = ?1
5673         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5674    } else {
5675        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
5676                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
5677         FROM nodes n JOIN files f ON f.path = n.file_path
5678         WHERE n.scoped_name = ?1 OR n.name = ?1
5679         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
5680    };
5681    let mut stmt = conn.prepare(sql)?;
5682    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
5683    rows.collect::<std::result::Result<Vec<_>, _>>()
5684        .map_err(Into::into)
5685}
5686
5687fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
5688    store_node_from_row_at(row, 0)
5689}
5690
5691fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
5692    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
5693    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
5694    let lang_label_value: String = row.get(offset + 10)?;
5695    Ok(StoreNode {
5696        node_id: row.get(offset)?,
5697        file: row.get(offset + 1)?,
5698        symbol: row.get(offset + 2)?,
5699        name: row.get(offset + 3)?,
5700        kind: row.get(offset + 4)?,
5701        line: start_line.saturating_add(1),
5702        end_line: end_line.saturating_add(1),
5703        signature: row.get(offset + 7)?,
5704        exported: row.get::<_, i64>(offset + 8)? != 0,
5705        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
5706        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
5707    })
5708}
5709
5710fn optional_store_node_from_row_at(
5711    row: &rusqlite::Row<'_>,
5712    offset: usize,
5713) -> rusqlite::Result<Option<StoreNode>> {
5714    if row.get::<_, Option<String>>(offset)?.is_some() {
5715        store_node_from_row_at(row, offset).map(Some)
5716    } else {
5717        Ok(None)
5718    }
5719}
5720
5721#[allow(clippy::too_many_arguments)]
5722fn collect_callers_recursive(
5723    conn: &Connection,
5724    file: &str,
5725    symbol: &str,
5726    max_depth: usize,
5727    current_depth: usize,
5728    visited: &mut HashSet<(String, String)>,
5729    result: &mut Vec<StoreCallSite>,
5730    depth_limited: &mut bool,
5731    truncated: &mut usize,
5732) -> Result<()> {
5733    if current_depth >= max_depth {
5734        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
5735        if omitted > 0 {
5736            *depth_limited = true;
5737            *truncated += omitted;
5738        }
5739        return Ok(());
5740    }
5741
5742    if !visited.insert((file.to_string(), symbol.to_string())) {
5743        return Ok(());
5744    }
5745
5746    let sites = direct_callers_for_tuple(conn, file, symbol)?;
5747    for site in sites {
5748        result.push(site.clone());
5749        if current_depth + 1 < max_depth {
5750            collect_callers_recursive(
5751                conn,
5752                &site.caller.file,
5753                &site.caller.symbol,
5754                max_depth,
5755                current_depth + 1,
5756                visited,
5757                result,
5758                depth_limited,
5759                truncated,
5760            )?;
5761        } else {
5762            let omitted =
5763                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
5764            if omitted > 0 {
5765                *depth_limited = true;
5766                *truncated += omitted;
5767            }
5768        }
5769    }
5770    Ok(())
5771}
5772
5773// Each target uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5774const DIRECT_CALLER_BATCH_SIZE: usize = 499;
5775
5776fn direct_caller_counts_for_tuples(
5777    conn: &Connection,
5778    targets: &[(String, String)],
5779) -> Result<HashMap<(String, String), usize>> {
5780    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5781    let mut counts = unique_targets
5782        .iter()
5783        .cloned()
5784        .map(|target| (target, 0usize))
5785        .collect::<HashMap<_, _>>();
5786
5787    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5788    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5789        let requested_values = (0..chunk.len())
5790            .map(|_| "(?, ?)")
5791            .collect::<Vec<_>>()
5792            .join(", ");
5793        let sql = format!(
5794            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values}),
5795             deduped AS (
5796                 SELECT e.target_file, e.target_symbol, src.file_path AS caller_file, e.line
5797                 FROM requested requested
5798                 JOIN edges e
5799                   ON e.target_file = requested.target_file
5800                  AND e.target_symbol = requested.target_symbol
5801                  AND e.kind = 'call'
5802                 JOIN refs r ON r.ref_id = e.ref_id
5803                 JOIN nodes src ON src.id = e.source_node
5804                 JOIN files src_file ON src_file.path = src.file_path
5805                 GROUP BY e.target_file, e.target_symbol, src.file_path, e.line
5806             )
5807             SELECT target_file, target_symbol, COUNT(*)
5808             FROM deduped
5809             GROUP BY target_file, target_symbol"
5810        );
5811        let bindings = chunk
5812            .iter()
5813            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5814        let mut stmt = conn.prepare(&sql)?;
5815        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5816            Ok((
5817                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
5818                row.get::<_, i64>(2)?,
5819            ))
5820        })?;
5821        for row in rows {
5822            let (target, count) = row?;
5823            counts.insert(target, usize::try_from(count).unwrap_or(usize::MAX));
5824        }
5825    }
5826
5827    Ok(counts)
5828}
5829
5830fn direct_caller_count_for_tuple(
5831    conn: &Connection,
5832    target_file: &str,
5833    target_symbol: &str,
5834) -> Result<usize> {
5835    let count: i64 = conn.query_row(
5836        "SELECT COUNT(*)
5837         FROM edges e
5838         JOIN refs r ON r.ref_id = e.ref_id
5839         JOIN nodes src ON src.id = e.source_node
5840         JOIN files src_file ON src_file.path = src.file_path
5841         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
5842        params![target_file, target_symbol],
5843        |row| row.get(0),
5844    )?;
5845    Ok(usize::try_from(count).unwrap_or(usize::MAX))
5846}
5847
5848fn direct_callers_for_tuple(
5849    conn: &Connection,
5850    target_file: &str,
5851    target_symbol: &str,
5852) -> Result<Vec<StoreCallSite>> {
5853    let mut stmt = conn.prepare(
5854        "SELECT e.target_file, e.target_symbol, e.line,
5855                r.byte_start, r.byte_end, r.status, e.provenance,
5856                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5857                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5858                src_file.lang,
5859                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5860                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5861                tgt_file.lang
5862         FROM edges e
5863         JOIN refs r ON r.ref_id = e.ref_id
5864         JOIN nodes src ON src.id = e.source_node
5865         JOIN files src_file ON src_file.path = src.file_path
5866         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5867             ON tgt.id = e.target_node
5868         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
5869         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
5870    )?;
5871    let rows = stmt.query_map(
5872        params![target_file, target_symbol],
5873        direct_call_site_from_row,
5874    )?;
5875    rows.collect::<std::result::Result<Vec<_>, _>>()
5876        .map_err(Into::into)
5877}
5878
5879fn direct_call_site_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreCallSite> {
5880    let caller = store_node_from_row_at(row, 7)?;
5881    let target = optional_store_node_from_row_at(row, 18)?;
5882    Ok(StoreCallSite {
5883        caller,
5884        target_file: row.get(0)?,
5885        target_symbol: row.get(1)?,
5886        target,
5887        line: row.get::<_, i64>(2)?.max(0) as u32,
5888        byte_start: row.get::<_, i64>(3)?.max(0) as usize,
5889        byte_end: row.get::<_, i64>(4)?.max(0) as usize,
5890        resolved: row.get::<_, String>(5)? == "resolved",
5891        provenance: row.get(6)?,
5892    })
5893}
5894
5895fn direct_callers_for_tuples(
5896    conn: &Connection,
5897    targets: &[(String, String)],
5898) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5899    let unique_targets = targets.iter().cloned().collect::<BTreeSet<_>>();
5900    let mut callers_by_target = unique_targets
5901        .iter()
5902        .cloned()
5903        .map(|target| (target, Vec::new()))
5904        .collect::<HashMap<_, _>>();
5905    let unique_targets = unique_targets.into_iter().collect::<Vec<_>>();
5906
5907    for chunk in unique_targets.chunks(DIRECT_CALLER_BATCH_SIZE) {
5908        let requested_values = (0..chunk.len())
5909            .map(|_| "(?, ?)")
5910            .collect::<Vec<_>>()
5911            .join(", ");
5912        let sql = format!(
5913            "WITH requested(target_file, target_symbol) AS (VALUES {requested_values})
5914             SELECT e.target_file, e.target_symbol, e.line,
5915                    r.byte_start, r.byte_end, r.status, e.provenance,
5916                    src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
5917                    src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
5918                    src_file.lang,
5919                    tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5920                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5921                    tgt_file.lang
5922             FROM requested requested
5923             JOIN edges e
5924               ON e.target_file = requested.target_file
5925              AND e.target_symbol = requested.target_symbol
5926              AND e.kind = 'call'
5927             JOIN refs r ON r.ref_id = e.ref_id
5928             JOIN nodes src ON src.id = e.source_node
5929             JOIN files src_file ON src_file.path = src.file_path
5930             LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
5931                 ON tgt.id = e.target_node
5932             ORDER BY e.target_file, e.target_symbol, e.source_node,
5933                      r.byte_start, r.line, r.ref_id"
5934        );
5935        let bindings = chunk
5936            .iter()
5937            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
5938        let mut stmt = conn.prepare(&sql)?;
5939        let rows = stmt.query_map(params_from_iter(bindings), |row| {
5940            let call = direct_call_site_from_row(row)?;
5941            let target_key = (call.target_file.clone(), call.target_symbol.clone());
5942            Ok((target_key, call))
5943        })?;
5944        for row in rows {
5945            let (target, call) = row?;
5946            callers_by_target
5947                .get_mut(&target)
5948                .expect("batched caller row belongs to a requested target")
5949                .push(call);
5950        }
5951    }
5952
5953    Ok(callers_by_target)
5954}
5955
5956// Each symbol uses two parameters; 499 stays below SQLite's legacy 999-variable limit.
5957const OUTGOING_SYMBOL_BATCH_SIZE: usize = 499;
5958// Outgoing-edge batches bind one source node per parameter.
5959const OUTGOING_NODE_BATCH_SIZE: usize = 999;
5960
5961fn outgoing_calls_for_symbol_tuples(
5962    conn: &Connection,
5963    sources: &[(String, String)],
5964) -> Result<HashMap<(String, String), Vec<StoreCallSite>>> {
5965    let unique_sources = sources.iter().cloned().collect::<BTreeSet<_>>();
5966    let unique_sources = unique_sources.into_iter().collect::<Vec<_>>();
5967    let source_nodes_by_symbol = nodes_for_symbol_tuples(conn, &unique_sources)?;
5968    let source_nodes = unique_sources
5969        .iter()
5970        .flat_map(|source| source_nodes_by_symbol.get(source).into_iter().flatten())
5971        .cloned()
5972        .collect::<Vec<_>>();
5973    let source_nodes_by_id = source_nodes
5974        .iter()
5975        .cloned()
5976        .map(|node| (node.node_id.clone(), node))
5977        .collect::<HashMap<_, _>>();
5978    let mut calls_by_node: HashMap<String, Vec<StoreCallSite>> = HashMap::new();
5979
5980    for chunk in source_nodes.chunks(OUTGOING_NODE_BATCH_SIZE) {
5981        let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
5982        let sql = format!(
5983            "SELECT e.source_node,
5984                    e.target_file, e.target_symbol, e.line,
5985                    r.byte_start, r.byte_end, r.status, e.provenance,
5986                    CASE WHEN tgt_file.lang IS NULL THEN NULL ELSE tgt.id END,
5987                    tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
5988                    tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
5989                    tgt_file.lang
5990             FROM edges e
5991             JOIN refs r ON r.ref_id = e.ref_id
5992             LEFT JOIN nodes tgt ON tgt.id = e.target_node
5993             LEFT JOIN files tgt_file ON tgt_file.path = tgt.file_path
5994             WHERE e.kind = 'call' AND e.source_node IN ({placeholders})
5995             ORDER BY e.source_node, r.byte_start, r.line, r.ref_id"
5996        );
5997        let bindings = chunk.iter().map(|node| node.node_id.as_str());
5998        let mut stmt = conn.prepare(&sql)?;
5999        let rows = stmt.query_map(params_from_iter(bindings), |row| {
6000            let source_node_id = row.get::<_, String>(0)?;
6001            let caller = source_nodes_by_id
6002                .get(&source_node_id)
6003                .expect("batched outgoing row belongs to a requested source node")
6004                .clone();
6005            let target = optional_store_node_from_row_at(row, 8)?;
6006            Ok((
6007                source_node_id,
6008                StoreCallSite {
6009                    caller,
6010                    target_file: row.get(1)?,
6011                    target_symbol: row.get(2)?,
6012                    target,
6013                    line: row.get::<_, i64>(3)?.max(0) as u32,
6014                    byte_start: row.get::<_, i64>(4)?.max(0) as usize,
6015                    byte_end: row.get::<_, i64>(5)?.max(0) as usize,
6016                    resolved: row.get::<_, String>(6)? == "resolved",
6017                    provenance: row.get(7)?,
6018                },
6019            ))
6020        })?;
6021        for row in rows {
6022            let (source_node_id, call) = row?;
6023            calls_by_node.entry(source_node_id).or_default().push(call);
6024        }
6025    }
6026
6027    let mut calls_by_source = HashMap::new();
6028    for source in &unique_sources {
6029        let mut calls = Vec::new();
6030        if let Some(nodes) = source_nodes_by_symbol.get(source) {
6031            for node in nodes {
6032                if let Some(node_calls) = calls_by_node.remove(&node.node_id) {
6033                    calls.extend(node_calls);
6034                }
6035            }
6036        }
6037        calls_by_source.insert(source.clone(), calls);
6038    }
6039
6040    // Resolve each logical target once for the whole frontier. Keeping this separate
6041    // preserves positional-symbol representatives without a correlated lookup per edge.
6042    let target_tuples = calls_by_source
6043        .values()
6044        .flatten()
6045        .map(|call| (call.target_file.clone(), call.target_symbol.clone()))
6046        .collect::<Vec<_>>();
6047    let target_nodes = nodes_for_symbol_tuples(conn, &target_tuples)?;
6048    for calls in calls_by_source.values_mut() {
6049        for call in calls {
6050            if let Some(target) = target_nodes
6051                .get(&(call.target_file.clone(), call.target_symbol.clone()))
6052                .and_then(|nodes| nodes.first())
6053            {
6054                call.target = Some(target.clone());
6055            }
6056        }
6057    }
6058
6059    Ok(calls_by_source)
6060}
6061
6062fn nodes_for_symbol_tuples(
6063    conn: &Connection,
6064    symbols: &[(String, String)],
6065) -> Result<HashMap<(String, String), Vec<StoreNode>>> {
6066    let unique_symbols = symbols.iter().cloned().collect::<BTreeSet<_>>();
6067    let mut nodes_by_symbol = unique_symbols
6068        .iter()
6069        .cloned()
6070        .map(|symbol| (symbol, Vec::new()))
6071        .collect::<HashMap<_, _>>();
6072    let unique_symbols = unique_symbols.into_iter().collect::<Vec<_>>();
6073
6074    for chunk in unique_symbols.chunks(OUTGOING_SYMBOL_BATCH_SIZE) {
6075        let requested_values = (0..chunk.len())
6076            .map(|_| "(?, ?)")
6077            .collect::<Vec<_>>()
6078            .join(", ");
6079        let sql = format!(
6080            "WITH requested(file, symbol) AS (VALUES {requested_values})
6081             SELECT requested.file, requested.symbol,
6082                    node.id, node.file_path, node.scoped_name, node.name, node.kind,
6083                    node.start_line, node.end_line, node.signature, node.exported,
6084                    node.is_callgraph_entry_point, node_file.lang
6085             FROM requested
6086             JOIN nodes node INDEXED BY idx_nodes_file
6087               ON node.file_path = requested.file
6088              AND node.scoped_name = requested.symbol
6089             JOIN files node_file ON node_file.path = node.file_path
6090             ORDER BY requested.file, requested.symbol,
6091                      node.scoped_name, node.start_line, node.end_line,
6092                      node.start_col, node.range_ordinal"
6093        );
6094        let bindings = chunk
6095            .iter()
6096            .flat_map(|(file, symbol)| [file.as_str(), symbol.as_str()]);
6097        let mut stmt = conn.prepare(&sql)?;
6098        let rows = stmt.query_map(params_from_iter(bindings), |row| {
6099            Ok((
6100                (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
6101                store_node_from_row_at(row, 2)?,
6102            ))
6103        })?;
6104        for row in rows {
6105            let (symbol, node) = row?;
6106            nodes_by_symbol.entry(symbol).or_default().push(node);
6107        }
6108    }
6109
6110    Ok(nodes_by_symbol)
6111}
6112
6113fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6114    let mut stmt = conn.prepare(
6115        "SELECT e.target_file, e.target_symbol, e.line,
6116                r.byte_start, r.byte_end, r.status, e.provenance,
6117                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6118                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6119                tgt_file.lang
6120         FROM edges e
6121         JOIN refs r ON r.ref_id = e.ref_id
6122         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6123             ON tgt.id = e.target_node
6124         WHERE e.kind = 'call' AND e.source_node = ?1
6125         ORDER BY r.byte_start, r.line, r.ref_id",
6126    )?;
6127    let rows = stmt.query_map(params![node.node_id], |row| {
6128        let target = optional_store_node_from_row_at(row, 7)?;
6129        Ok(StoreCallSite {
6130            caller: node.clone(),
6131            target_file: row.get(0)?,
6132            target_symbol: row.get(1)?,
6133            target,
6134            line: row.get::<_, i64>(2)?.max(0) as u32,
6135            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6136            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6137            resolved: row.get::<_, String>(5)? == "resolved",
6138            provenance: row.get(6)?,
6139        })
6140    })?;
6141    rows.collect::<std::result::Result<Vec<_>, _>>()
6142        .map_err(Into::into)
6143}
6144
6145fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
6146    let mut stmt = conn.prepare(
6147        "SELECT r.target_file, r.target_symbol, r.line,
6148                r.byte_start, r.byte_end, r.status, r.provenance,
6149                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
6150                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
6151                tgt_file.lang
6152         FROM refs r
6153         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
6154             ON tgt.id = r.target_node
6155         WHERE r.caller_node = ?1
6156           AND r.kind = 'call'
6157           AND r.status <> 'unresolved'
6158           AND r.target_file = ?2
6159           AND r.target_symbol = ?3
6160           AND r.provenance = ?4
6161           AND NOT EXISTS (
6162               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
6163           )
6164         ORDER BY r.byte_start, r.line, r.ref_id",
6165    )?;
6166    let rows = stmt.query_map(
6167        params![
6168            &node.node_id,
6169            &node.file,
6170            &node.symbol,
6171            PROVENANCE_TREESITTER
6172        ],
6173        |row| {
6174            let target = optional_store_node_from_row_at(row, 7)?;
6175            Ok(StoreCallSite {
6176                caller: node.clone(),
6177                target_file: row.get(0)?,
6178                target_symbol: row.get(1)?,
6179                target,
6180                line: row.get::<_, i64>(2)?.max(0) as u32,
6181                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6182                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6183                resolved: row.get::<_, String>(5)? == "resolved",
6184                provenance: row.get(6)?,
6185            })
6186        },
6187    )?;
6188    rows.collect::<std::result::Result<Vec<_>, _>>()
6189        .map_err(Into::into)
6190}
6191
6192fn unresolved_calls_for_node(
6193    conn: &Connection,
6194    node: &StoreNode,
6195) -> Result<Vec<StoreUnresolvedCall>> {
6196    let mut stmt = conn.prepare(
6197        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
6198         FROM refs
6199         WHERE caller_node = ?1
6200           AND kind = 'call'
6201           AND status = 'unresolved'
6202           AND NOT EXISTS (
6203               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6204           )
6205         ORDER BY byte_start, line, ref_id",
6206    )?;
6207    let rows = stmt.query_map(params![node.node_id], |row| {
6208        Ok(StoreUnresolvedCall {
6209            caller: node.clone(),
6210            symbol: row.get(0)?,
6211            full_ref: row.get(1)?,
6212            line: row.get::<_, i64>(2)?.max(0) as u32,
6213            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
6214            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
6215        })
6216    })?;
6217    rows.collect::<std::result::Result<Vec<_>, _>>()
6218        .map_err(Into::into)
6219}
6220
6221fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
6222    let mut calls = Vec::new();
6223    calls.extend(
6224        outgoing_calls_for_node(conn, node)?
6225            .into_iter()
6226            .map(StoreForwardCall::Resolved),
6227    );
6228    calls.extend(
6229        unresolved_calls_for_node(conn, node)?
6230            .into_iter()
6231            .map(StoreForwardCall::Unresolved),
6232    );
6233    calls.sort_by(|left, right| {
6234        left.byte_start()
6235            .cmp(&right.byte_start())
6236            .then(left.line().cmp(&right.line()))
6237    });
6238    Ok(calls)
6239}
6240
6241fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
6242    let resolved_count: i64 = conn.query_row(
6243        "SELECT COUNT(*)
6244         FROM edges e
6245         JOIN refs r ON r.ref_id = e.ref_id
6246         WHERE e.kind = 'call' AND e.source_node = ?1",
6247        params![&node.node_id],
6248        |row| row.get(0),
6249    )?;
6250    let unresolved_count: i64 = conn.query_row(
6251        "SELECT COUNT(*)
6252         FROM refs
6253         WHERE caller_node = ?1
6254           AND kind = 'call'
6255           AND status = 'unresolved'
6256           AND NOT EXISTS (
6257               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
6258           )",
6259        params![&node.node_id],
6260        |row| row.get(0),
6261    )?;
6262    let total = resolved_count.saturating_add(unresolved_count);
6263    Ok(usize::try_from(total).unwrap_or(usize::MAX))
6264}
6265
6266fn call_tree_inner(
6267    conn: &Connection,
6268    node: &StoreNode,
6269    max_depth: usize,
6270    current_depth: usize,
6271    visited: &mut HashSet<(String, String)>,
6272) -> Result<callgraph::CallTreeNode> {
6273    let visit_key = (node.file.clone(), node.symbol.clone());
6274    if visited.contains(&visit_key) {
6275        return Ok(callgraph::CallTreeNode {
6276            name: node.symbol.clone(),
6277            file: node.file.clone(),
6278            line: node.line,
6279            signature: node.signature.clone(),
6280            resolved: true,
6281            children: Vec::new(),
6282            depth_limited: false,
6283            truncated: 0,
6284        });
6285    }
6286    visited.insert(visit_key.clone());
6287
6288    let mut children = Vec::new();
6289    let mut depth_limited = false;
6290    let mut truncated = 0usize;
6291
6292    if current_depth < max_depth {
6293        let calls = forward_calls_for_node(conn, node)?;
6294        for call in calls {
6295            match call {
6296                StoreForwardCall::Resolved(site) => {
6297                    if let Some(target) = site.target {
6298                        let child =
6299                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
6300                        depth_limited |= child.depth_limited;
6301                        truncated += child.truncated;
6302                        children.push(child);
6303                    } else {
6304                        children.push(callgraph::CallTreeNode {
6305                            name: site.target_symbol,
6306                            file: site.target_file,
6307                            line: site.line,
6308                            signature: None,
6309                            resolved: false,
6310                            children: Vec::new(),
6311                            depth_limited: false,
6312                            truncated: 0,
6313                        });
6314                    }
6315                }
6316                StoreForwardCall::Unresolved(call) => {
6317                    children.push(callgraph::CallTreeNode {
6318                        name: call.symbol,
6319                        file: call.caller.file,
6320                        line: call.line,
6321                        signature: None,
6322                        resolved: false,
6323                        children: Vec::new(),
6324                        depth_limited: false,
6325                        truncated: 0,
6326                    });
6327                }
6328            }
6329        }
6330    } else {
6331        truncated = forward_call_count_for_node(conn, node)?;
6332        depth_limited = truncated > 0;
6333    }
6334
6335    visited.remove(&visit_key);
6336    Ok(callgraph::CallTreeNode {
6337        name: node.symbol.clone(),
6338        file: node.file.clone(),
6339        line: node.line,
6340        signature: node.signature.clone(),
6341        resolved: true,
6342        children,
6343        depth_limited,
6344        truncated,
6345    })
6346}
6347
6348fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
6349    callgraph::TraceToSymbolHop {
6350        symbol: node.symbol.clone(),
6351        file: node.file.clone(),
6352        line: node.line,
6353    }
6354}
6355
6356fn trace_to_symbol_matches_target(
6357    node: &StoreNode,
6358    to_symbol: &str,
6359    to_file: Option<&str>,
6360) -> bool {
6361    if !symbol_query_matches(&node.symbol, to_symbol) {
6362        return false;
6363    }
6364    match to_file {
6365        Some(file) => node.file == file,
6366        None => true,
6367    }
6368}
6369
6370fn symbol_query_matches(symbol: &str, query: &str) -> bool {
6371    symbol == query || unqualified_name(symbol) == query
6372}
6373
6374fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
6375    let source = std::fs::read_to_string(path).ok()?;
6376    Some(source.lines().map(|line| line.trim().to_string()).collect())
6377}
6378
6379#[doc(hidden)]
6380pub fn live_callgraph_edge_snapshot(
6381    project_root: &Path,
6382    files: &[PathBuf],
6383) -> Result<BTreeSet<StoredEdge>> {
6384    let files = normalize_file_list(project_root, files)?;
6385    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
6386    let mut file_data = Vec::new();
6387    for file in &files {
6388        let canon = canonicalize_path(file);
6389        let data = graph.build_file(&canon)?.clone();
6390        file_data.push((canon, data));
6391    }
6392
6393    let mut edges = BTreeSet::new();
6394    for (caller_file, data) in &file_data {
6395        for (caller_symbol, call_sites) in &data.calls_by_symbol {
6396            for call_site in call_sites {
6397                let resolution = graph.resolve_cross_file_edge(
6398                    &call_site.full_callee,
6399                    &call_site.callee_name,
6400                    caller_file,
6401                    &data.import_block,
6402                );
6403                let (target_file, target_symbol) = match resolution {
6404                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
6405                    EdgeResolution::Unresolved { callee_name } => {
6406                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
6407                            continue;
6408                        }
6409                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
6410                            data,
6411                            caller_file,
6412                            &callee_name,
6413                        ) else {
6414                            continue;
6415                        };
6416                        (caller_file.clone(), target_symbol)
6417                    }
6418                };
6419                if target_file == *caller_file && target_symbol == *caller_symbol {
6420                    continue;
6421                }
6422                edges.insert(StoredEdge {
6423                    source_file: relative_path(project_root, caller_file),
6424                    source_symbol: caller_symbol.clone(),
6425                    target_file: relative_path(project_root, &target_file),
6426                    target_symbol,
6427                    kind: "call".to_string(),
6428                    line: call_site.line,
6429                });
6430            }
6431        }
6432    }
6433    Ok(edges)
6434}
6435
6436fn rebuild_cooldown_records() -> &'static Mutex<HashMap<RebuildCooldownKey, RebuildCooldownRecord>>
6437{
6438    SUCCESSFUL_REBUILDS.get_or_init(|| Mutex::new(HashMap::new()))
6439}
6440
6441fn rebuild_cooldown_key(callgraph_dir: &Path, project_key: &str) -> RebuildCooldownKey {
6442    RebuildCooldownKey {
6443        callgraph_dir: std::fs::canonicalize(callgraph_dir)
6444            .unwrap_or_else(|_| callgraph_dir.to_path_buf()),
6445        project_key: project_key.to_string(),
6446    }
6447}
6448
6449fn rebuild_cooldown_denial(
6450    callgraph_dir: &Path,
6451    project_key: &str,
6452    project_root: &Path,
6453    now: Instant,
6454) -> Option<(PathBuf, Duration)> {
6455    let key = rebuild_cooldown_key(callgraph_dir, project_key);
6456    let records = rebuild_cooldown_records()
6457        .lock()
6458        .unwrap_or_else(std::sync::PoisonError::into_inner);
6459    let record = records.get(&key)?;
6460    if record.project_root == project_root || !record.cross_root_cooldown_armed {
6461        return None;
6462    }
6463    let elapsed = now.saturating_duration_since(record.published_at);
6464    (elapsed < REBUILD_COOLDOWN).then(|| (record.project_root.clone(), REBUILD_COOLDOWN - elapsed))
6465}
6466
6467fn record_successful_rebuild(
6468    callgraph_dir: &Path,
6469    project_key: &str,
6470    project_root: &Path,
6471    published_at: Instant,
6472) {
6473    let key = rebuild_cooldown_key(callgraph_dir, project_key);
6474    let mut records = rebuild_cooldown_records()
6475        .lock()
6476        .unwrap_or_else(std::sync::PoisonError::into_inner);
6477    if records.len() >= 4_096 && !records.contains_key(&key) {
6478        if let Some(evict) = records.keys().next().cloned() {
6479            records.remove(&evict);
6480        }
6481    }
6482    let cross_root_cooldown_armed = records.get(&key).is_some_and(|previous| {
6483        previous.cross_root_cooldown_armed || previous.project_root != project_root
6484    });
6485    records.insert(
6486        key,
6487        RebuildCooldownRecord {
6488            project_root: project_root.to_path_buf(),
6489            published_at,
6490            cross_root_cooldown_armed,
6491        },
6492    );
6493}
6494
6495fn acquire_writer_lease(
6496    callgraph_dir: &Path,
6497    project_key: &str,
6498    project_root: &Path,
6499) -> Result<Option<Arc<crate::root_cache::WriterLease>>> {
6500    crate::root_cache::WriterLease::acquire_shared(
6501        crate::root_cache::RootCacheDomain::Callgraph,
6502        callgraph_dir,
6503        project_key,
6504        project_root,
6505    )
6506    .map_err(CallGraphStoreError::from)
6507}
6508
6509fn verify_writer_lease(lease: &crate::root_cache::WriterLease) -> Result<()> {
6510    if lease.verify()? {
6511        Ok(())
6512    } else {
6513        Err(CallGraphStoreError::Unavailable(format!(
6514            "callgraph writer lease for key {} lost epoch {}; aborting write",
6515            lease.key(),
6516            lease.epoch()
6517        )))
6518    }
6519}
6520
6521fn legacy_migration_completion_line(
6522    project_key: &str,
6523    method: &str,
6524    legacy_bytes: u64,
6525    migrated_bytes: u64,
6526) -> String {
6527    format!(
6528        "migrated root-keyed callgraph store key={project_key} method={method} legacy={legacy_bytes} migrated={migrated_bytes}"
6529    )
6530}
6531
6532fn log_legacy_migration_completion(
6533    project_key: &str,
6534    method: &str,
6535    legacy_bytes: u64,
6536    migrated_bytes: u64,
6537) {
6538    crate::slog_info!(
6539        "{}",
6540        legacy_migration_completion_line(project_key, method, legacy_bytes, migrated_bytes)
6541    );
6542}
6543
6544fn try_legacy_migration_or_fallback(
6545    callgraph_dir: &Path,
6546    project_root: &Path,
6547    project_key: &str,
6548    writer_lease: Arc<crate::root_cache::WriterLease>,
6549) -> Result<Option<CallGraphStore>> {
6550    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6551    if partitions.is_empty() {
6552        return Ok(None);
6553    }
6554
6555    for partition in &partitions {
6556        if let Some(source) = newest_superseded_legacy_generation(partition)? {
6557            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6558                return open_legacy_fallback_store(
6559                    callgraph_dir,
6560                    project_root,
6561                    project_key,
6562                    &partitions,
6563                );
6564            }
6565            match publish_generation_copy_migration(
6566                callgraph_dir,
6567                project_key,
6568                &source,
6569                Arc::clone(&writer_lease),
6570            ) {
6571                Ok(published) => {
6572                    log_legacy_migration_completion(
6573                        project_key,
6574                        "generation_copy",
6575                        source.source_bytes,
6576                        published.migrated_bytes,
6577                    );
6578                    return CallGraphStore::open_generation(
6579                        callgraph_dir,
6580                        project_root.to_path_buf(),
6581                        project_key.to_string(),
6582                        published.generation,
6583                        writer_lease,
6584                    )
6585                    .map(Some);
6586                }
6587                Err(error) => {
6588                    crate::slog_warn!(
6589                        "root-keyed callgraph generation-copy migration failed from {}: {}",
6590                        source.sqlite_path.display(),
6591                        error
6592                    );
6593                    return open_legacy_fallback_store(
6594                        callgraph_dir,
6595                        project_root,
6596                        project_key,
6597                        &partitions,
6598                    );
6599                }
6600            }
6601        }
6602
6603        if let Some(source) = current_legacy_generation(partition)? {
6604            if !migration_disk_floor_allows(&source, callgraph_dir)? {
6605                return open_legacy_fallback_store(
6606                    callgraph_dir,
6607                    project_root,
6608                    project_key,
6609                    &partitions,
6610                );
6611            }
6612            match publish_backup_migration(
6613                callgraph_dir,
6614                project_key,
6615                &source,
6616                Arc::clone(&writer_lease),
6617            ) {
6618                Ok(published) => {
6619                    log_legacy_migration_completion(
6620                        project_key,
6621                        "sqlite_backup",
6622                        source.source_bytes,
6623                        published.migrated_bytes,
6624                    );
6625                    return CallGraphStore::open_generation(
6626                        callgraph_dir,
6627                        project_root.to_path_buf(),
6628                        project_key.to_string(),
6629                        published.generation,
6630                        writer_lease,
6631                    )
6632                    .map(Some);
6633                }
6634                Err(error) => {
6635                    crate::slog_warn!(
6636                        "root-keyed callgraph backup migration failed from {}: {}",
6637                        source.sqlite_path.display(),
6638                        error
6639                    );
6640                    return open_legacy_fallback_store(
6641                        callgraph_dir,
6642                        project_root,
6643                        project_key,
6644                        &partitions,
6645                    );
6646                }
6647            }
6648        }
6649    }
6650
6651    open_legacy_fallback_store(callgraph_dir, project_root, project_key, &partitions)
6652}
6653
6654fn open_legacy_fallback_store(
6655    callgraph_dir: &Path,
6656    project_root: &Path,
6657    project_key: &str,
6658    partitions: &[LegacyCallgraphPartition],
6659) -> Result<Option<CallGraphStore>> {
6660    let Some(target) = first_ready_legacy_target(partitions)? else {
6661        return Ok(None);
6662    };
6663    crate::slog_warn!(
6664        "root-keyed callgraph migration unavailable; serving read-only fallback from legacy {} partition {}",
6665        target.partition.harness,
6666        target.sqlite_path.display()
6667    );
6668    let conn = open_readonly_connection(&target.sqlite_path)?;
6669    if !database_ready(&conn).unwrap_or(false) {
6670        return Ok(None);
6671    }
6672    let marker_label = legacy_read_marker_label(&target.sqlite_path, target.generation.as_deref());
6673    let read_marker = crate::root_cache::ReadMarker::create(callgraph_dir, &marker_label)?;
6674    Ok(Some(CallGraphStore::from_connection(
6675        project_root.to_path_buf(),
6676        project_key.to_string(),
6677        target.sqlite_path,
6678        callgraph_dir.to_path_buf(),
6679        true,
6680        target.generation,
6681        None,
6682        Some(read_marker),
6683        conn,
6684    )))
6685}
6686
6687fn migration_disk_floor_allows(
6688    source: &LegacyCallgraphTarget,
6689    callgraph_dir: &Path,
6690) -> Result<bool> {
6691    let available = migration_available_disk(callgraph_dir)?;
6692    let decision = crate::legacy_partitions::evaluate_root_keyed_copy_disk_floor(
6693        source.source_bytes,
6694        available,
6695    );
6696    if decision.should_skip_copy() {
6697        crate::slog_warn!(
6698            "{}",
6699            decision.warning_message(&source.sqlite_path, callgraph_dir)
6700        );
6701        return Ok(false);
6702    }
6703    Ok(true)
6704}
6705
6706fn migration_available_disk(path: &Path) -> Result<u64> {
6707    if let Some(bytes) = MIGRATION_AVAILABLE_DISK_OVERRIDE.with(|slot| *slot.borrow()) {
6708        return Ok(bytes);
6709    }
6710    crate::legacy_partitions::available_disk_for(path).map_err(CallGraphStoreError::from)
6711}
6712
6713fn legacy_callgraph_partitions(
6714    callgraph_dir: &Path,
6715    project_key: &str,
6716) -> Result<Vec<LegacyCallgraphPartition>> {
6717    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6718        return Ok(Vec::new());
6719    };
6720    let inventory = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?;
6721    let mut partitions = inventory
6722        .into_iter()
6723        .filter(|entry| {
6724            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6725                && entry.key == project_key
6726        })
6727        .map(|entry| {
6728            let dir = if entry.path.is_dir() {
6729                entry.path.clone()
6730            } else {
6731                entry
6732                    .path
6733                    .parent()
6734                    .map(Path::to_path_buf)
6735                    .unwrap_or_else(|| entry.path.clone())
6736            };
6737            LegacyCallgraphPartition {
6738                harness: entry.harness,
6739                dir,
6740                key: entry.key,
6741                bytes: entry.bytes,
6742                freshness: entry.callgraph_pointer_mtime,
6743            }
6744        })
6745        .collect::<Vec<_>>();
6746    partitions.sort_by(|left, right| {
6747        right
6748            .freshness
6749            .cmp(&left.freshness)
6750            .then_with(|| right.bytes.cmp(&left.bytes))
6751            .then_with(|| left.harness.cmp(&right.harness))
6752    });
6753    Ok(partitions)
6754}
6755
6756fn root_storage_dir(callgraph_dir: &Path) -> Option<PathBuf> {
6757    let domain_dir = callgraph_dir.parent()?;
6758    if domain_dir.file_name().and_then(|name| name.to_str()) != Some("callgraph") {
6759        return None;
6760    }
6761    domain_dir.parent().map(Path::to_path_buf)
6762}
6763
6764pub(crate) fn all_legacy_partitions_migrated_for_keys(
6765    callgraph_dir: &Path,
6766    configured_keys: &BTreeSet<String>,
6767) -> Result<bool> {
6768    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
6769        return Ok(false);
6770    };
6771    let legacy_keys = crate::legacy_partitions::inventory_legacy_partitions(&storage_root)?
6772        .into_iter()
6773        .filter(|entry| {
6774            entry.kind == crate::legacy_partitions::LegacyPartitionKind::Callgraph
6775                && configured_keys.contains(&entry.key)
6776        })
6777        .map(|entry| entry.key)
6778        .collect::<BTreeSet<_>>();
6779    if legacy_keys.is_empty() {
6780        return Ok(false);
6781    }
6782
6783    for key in legacy_keys {
6784        let migrated_dir = storage_root.join("callgraph").join(&key);
6785        let Some(generation) = read_pointer(&migrated_dir, &key) else {
6786            return Ok(false);
6787        };
6788        if !migration_generation_requires_manifest(&generation)
6789            || !migration_manifest_valid(&migrated_dir, &generation)
6790        {
6791            return Ok(false);
6792        }
6793    }
6794    Ok(true)
6795}
6796
6797fn newest_superseded_legacy_generation(
6798    partition: &LegacyCallgraphPartition,
6799) -> Result<Option<LegacyCallgraphTarget>> {
6800    let Some(current) = read_pointer(&partition.dir, &partition.key) else {
6801        return Ok(None);
6802    };
6803    let prefix = format!("{}.g", partition.key);
6804    let Ok(entries) = std::fs::read_dir(&partition.dir) else {
6805        return Ok(None);
6806    };
6807    let mut candidates = Vec::new();
6808    for entry in entries.flatten() {
6809        let name = entry.file_name().to_string_lossy().to_string();
6810        if name == current
6811            || name.contains(".tmp.")
6812            || !name.starts_with(&prefix)
6813            || !name.ends_with(".sqlite")
6814        {
6815            continue;
6816        }
6817        let path = entry.path();
6818        if !db_path_ready(&path) {
6819            continue;
6820        }
6821        let modified = entry
6822            .metadata()
6823            .and_then(|metadata| metadata.modified())
6824            .unwrap_or(SystemTime::UNIX_EPOCH);
6825        candidates.push((modified, path, name));
6826    }
6827    candidates.sort_by(|left, right| right.0.cmp(&left.0));
6828    let Some((_modified, sqlite_path, generation)) = candidates.into_iter().next() else {
6829        return Ok(None);
6830    };
6831    let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6832    Ok(Some(LegacyCallgraphTarget {
6833        partition: partition.clone(),
6834        sqlite_path,
6835        generation: Some(generation),
6836        source_bytes,
6837        source_blake3: String::new(),
6838    }))
6839}
6840
6841fn current_legacy_generation(
6842    partition: &LegacyCallgraphPartition,
6843) -> Result<Option<LegacyCallgraphTarget>> {
6844    let Some(target) = ready_legacy_target(partition)? else {
6845        return Ok(None);
6846    };
6847    let has_superseded = newest_superseded_legacy_generation(partition)?.is_some();
6848    if has_superseded {
6849        return Ok(None);
6850    }
6851    Ok(Some(target))
6852}
6853
6854fn freshest_legacy_fallback_target(
6855    callgraph_dir: &Path,
6856    project_key: &str,
6857) -> Result<Option<LegacyCallgraphTarget>> {
6858    let partitions = legacy_callgraph_partitions(callgraph_dir, project_key)?;
6859    first_ready_legacy_target(&partitions)
6860}
6861
6862fn first_ready_legacy_target(
6863    partitions: &[LegacyCallgraphPartition],
6864) -> Result<Option<LegacyCallgraphTarget>> {
6865    for partition in partitions {
6866        if let Some(target) = ready_legacy_target(partition)? {
6867            return Ok(Some(target));
6868        }
6869    }
6870    Ok(None)
6871}
6872
6873fn ready_legacy_target(
6874    partition: &LegacyCallgraphPartition,
6875) -> Result<Option<LegacyCallgraphTarget>> {
6876    if let Some(generation) = read_pointer(&partition.dir, &partition.key) {
6877        let sqlite_path = partition.dir.join(&generation);
6878        if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6879            let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6880            return Ok(Some(LegacyCallgraphTarget {
6881                partition: partition.clone(),
6882                sqlite_path,
6883                generation: Some(generation),
6884                source_bytes,
6885                source_blake3: String::new(),
6886            }));
6887        }
6888    }
6889
6890    let sqlite_path = legacy_sqlite_path(&partition.dir, &partition.key);
6891    if sqlite_path.is_file() && db_path_ready(&sqlite_path) {
6892        let source_bytes = sqlite_file_set_size(&sqlite_path)?;
6893        return Ok(Some(LegacyCallgraphTarget {
6894            partition: partition.clone(),
6895            sqlite_path,
6896            generation: None,
6897            source_bytes,
6898            source_blake3: String::new(),
6899        }));
6900    }
6901    Ok(None)
6902}
6903
6904fn publish_generation_copy_migration(
6905    callgraph_dir: &Path,
6906    project_key: &str,
6907    source: &LegacyCallgraphTarget,
6908    writer_lease: Arc<crate::root_cache::WriterLease>,
6909) -> Result<PublishedLegacyMigration> {
6910    let generation = migration_generation_file_name(project_key, "copy");
6911    let temp_path = migration_temp_path(callgraph_dir, &generation);
6912    remove_sqlite_file_set(&temp_path);
6913    copy_sqlite_file_set(&source.sqlite_path, &temp_path)?;
6914    fail_after_temp_copy_for_test()?;
6915
6916    let mut source = source.clone();
6917    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
6918    source.source_blake3 = fingerprint.blake3;
6919    let generation = publish_migrated_generation(
6920        callgraph_dir,
6921        project_key,
6922        &generation,
6923        &temp_path,
6924        &source,
6925        fingerprint.bytes,
6926        writer_lease,
6927        "generation_copy",
6928    )?;
6929    Ok(PublishedLegacyMigration {
6930        generation,
6931        migrated_bytes: fingerprint.bytes,
6932    })
6933}
6934
6935fn publish_backup_migration(
6936    callgraph_dir: &Path,
6937    project_key: &str,
6938    source: &LegacyCallgraphTarget,
6939    writer_lease: Arc<crate::root_cache::WriterLease>,
6940) -> Result<PublishedLegacyMigration> {
6941    if MIGRATION_FORCE_BACKUP_BUDGET_EXHAUSTED.with(|slot| slot.get()) {
6942        return Err(CallGraphStoreError::Unavailable(
6943            "legacy callgraph backup migration budget exhausted by test seam".to_string(),
6944        ));
6945    }
6946
6947    let generation = migration_generation_file_name(project_key, "backup");
6948    let temp_path = migration_temp_path(callgraph_dir, &generation);
6949    remove_sqlite_file_set(&temp_path);
6950
6951    let source_conn = open_readonly_connection(&source.sqlite_path)?;
6952    let mut destination = TrackedConnection::open(&temp_path, SqliteStore::CallgraphGeneration)?;
6953    destination.busy_timeout(Duration::from_secs(5))?;
6954    let backup = rusqlite::backup::Backup::new(&source_conn, &mut destination)?;
6955    let started = Instant::now();
6956    let mut retries = 0;
6957    loop {
6958        match backup.step(MIGRATION_BACKUP_PAGES_PER_STEP)? {
6959            rusqlite::backup::StepResult::Done => break,
6960            rusqlite::backup::StepResult::More => std::thread::sleep(Duration::from_millis(5)),
6961            rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => {
6962                retries += 1;
6963                if retries > MIGRATION_BACKUP_RETRY_BUDGET
6964                    || started.elapsed() > MIGRATION_BACKUP_WALL_CLOCK_BUDGET
6965                {
6966                    return Err(CallGraphStoreError::Unavailable(format!(
6967                        "legacy callgraph backup migration exceeded retry/wall-clock budget after {retries} retries"
6968                    )));
6969                }
6970                std::thread::sleep(Duration::from_millis(20));
6971            }
6972            _ => {
6973                return Err(CallGraphStoreError::Unavailable(
6974                    "legacy callgraph backup returned an unknown step result".to_string(),
6975                ));
6976            }
6977        }
6978    }
6979    drop(backup);
6980
6981    let integrity: String =
6982        destination.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
6983    if integrity != "ok" {
6984        return Err(CallGraphStoreError::Unavailable(format!(
6985            "legacy callgraph backup produced a database that failed integrity_check: {integrity}"
6986        )));
6987    }
6988    if !database_ready(&destination)? {
6989        return Err(CallGraphStoreError::Unavailable(
6990            "legacy callgraph backup produced a database without ready metadata".to_string(),
6991        ));
6992    }
6993    destination.execute_batch("PRAGMA optimize;")?;
6994    drop(destination);
6995    sync_file(&temp_path)?;
6996    fail_after_temp_copy_for_test()?;
6997
6998    let mut source = source.clone();
6999    let fingerprint = sqlite_file_set_fingerprint(&temp_path)?;
7000    source.source_blake3 = fingerprint.blake3;
7001    let generation = publish_migrated_generation(
7002        callgraph_dir,
7003        project_key,
7004        &generation,
7005        &temp_path,
7006        &source,
7007        fingerprint.bytes,
7008        writer_lease,
7009        "sqlite_backup",
7010    )?;
7011    Ok(PublishedLegacyMigration {
7012        generation,
7013        migrated_bytes: fingerprint.bytes,
7014    })
7015}
7016
7017fn publish_migrated_generation(
7018    callgraph_dir: &Path,
7019    project_key: &str,
7020    generation: &str,
7021    temp_path: &Path,
7022    source: &LegacyCallgraphTarget,
7023    migrated_bytes: u64,
7024    writer_lease: Arc<crate::root_cache::WriterLease>,
7025    method: &str,
7026) -> Result<String> {
7027    let gen_path = callgraph_dir.join(generation);
7028    checkpoint_sqlite_before_publication(temp_path);
7029    let publication = publish_if_current(|| {
7030        verify_writer_lease(&writer_lease)?;
7031        remove_sqlite_file_set(&gen_path);
7032        rename_sqlite_file_set(temp_path, &gen_path)?;
7033        crate::fs_lock::sync_parent(&gen_path);
7034
7035        verify_writer_lease(&writer_lease)?;
7036        publish_pointer(callgraph_dir, project_key, generation)?;
7037        write_migration_manifest(callgraph_dir, generation, source, migrated_bytes, method)?;
7038        Ok(generation.to_string())
7039    });
7040    if matches!(publication, Err(CallGraphStoreError::Superseded)) {
7041        remove_sqlite_file_set(temp_path);
7042    }
7043    publication
7044}
7045
7046fn copy_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7047    if let Some(parent) = destination.parent() {
7048        std::fs::create_dir_all(parent)?;
7049    }
7050    for suffix in SQLITE_FILE_SET_SUFFIXES {
7051        let source_path = sqlite_file_set_path(source, suffix);
7052        if !source_path.is_file() {
7053            continue;
7054        }
7055        let destination_path = sqlite_file_set_path(destination, suffix);
7056        std::fs::copy(&source_path, &destination_path)?;
7057        sync_file(&destination_path)?;
7058    }
7059    Ok(())
7060}
7061
7062fn rename_sqlite_file_set(source: &Path, destination: &Path) -> Result<()> {
7063    for suffix in SQLITE_FILE_SET_SUFFIXES {
7064        let source_path = sqlite_file_set_path(source, suffix);
7065        if !source_path.exists() {
7066            continue;
7067        }
7068        let destination_path = sqlite_file_set_path(destination, suffix);
7069        if let Err(error) = crate::fs_lock::rename_over(&source_path, &destination_path) {
7070            let _ = std::fs::remove_file(&source_path);
7071            return Err(error.into());
7072        }
7073    }
7074    Ok(())
7075}
7076
7077fn sqlite_file_set_size(path: &Path) -> Result<u64> {
7078    let mut bytes = 0_u64;
7079    for suffix in SQLITE_FILE_SET_SUFFIXES {
7080        let member = sqlite_file_set_path(path, suffix);
7081        if !member.is_file() {
7082            continue;
7083        }
7084        bytes = bytes.saturating_add(member.metadata()?.len());
7085    }
7086    Ok(bytes)
7087}
7088
7089fn sqlite_file_set_fingerprint(path: &Path) -> Result<SourceFingerprint> {
7090    let mut hasher = blake3::Hasher::new();
7091    let mut bytes = 0_u64;
7092    let mut buffer = [0_u8; 64 * 1024];
7093    for suffix in SQLITE_FILE_SET_SUFFIXES {
7094        let member = sqlite_file_set_path(path, suffix);
7095        if !member.is_file() {
7096            continue;
7097        }
7098        hasher.update(suffix.as_bytes());
7099        let mut file = std::fs::File::open(&member)?;
7100        loop {
7101            let read = file.read(&mut buffer)?;
7102            if read == 0 {
7103                break;
7104            }
7105            bytes = bytes.saturating_add(read as u64);
7106            hasher.update(&buffer[..read]);
7107        }
7108    }
7109    Ok(SourceFingerprint {
7110        bytes,
7111        blake3: hash_to_hex(hasher.finalize()),
7112    })
7113}
7114
7115fn sqlite_file_set_path(path: &Path, suffix: &str) -> PathBuf {
7116    if suffix.is_empty() {
7117        path.to_path_buf()
7118    } else {
7119        PathBuf::from(format!("{}{suffix}", path.display()))
7120    }
7121}
7122
7123fn sync_file(path: &Path) -> Result<()> {
7124    let file = std::fs::OpenOptions::new()
7125        .read(true)
7126        .write(true)
7127        .open(path)?;
7128    file.sync_all()?;
7129    Ok(())
7130}
7131
7132fn fail_after_temp_copy_for_test() -> Result<()> {
7133    if MIGRATION_FAIL_AFTER_TEMP_COPY.with(|slot| slot.get()) {
7134        return Err(CallGraphStoreError::Unavailable(
7135            "legacy callgraph migration stopped after temp copy by test seam".to_string(),
7136        ));
7137    }
7138    Ok(())
7139}
7140
7141fn migration_generation_file_name(project_key: &str, method: &str) -> String {
7142    format!(
7143        "{project_key}.g{}.{}{}{}.sqlite",
7144        now_nanos(),
7145        std::process::id(),
7146        MIGRATION_GENERATION_TAG,
7147        method
7148    )
7149}
7150
7151fn migration_temp_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7152    callgraph_dir.join(format!(
7153        "{generation}.tmp.{}.{}",
7154        std::process::id(),
7155        now_nanos()
7156    ))
7157}
7158
7159fn write_migration_manifest(
7160    callgraph_dir: &Path,
7161    generation: &str,
7162    source: &LegacyCallgraphTarget,
7163    migrated_bytes: u64,
7164    method: &str,
7165) -> Result<()> {
7166    let manifest_path = migration_manifest_path(callgraph_dir, generation);
7167    let temp_path = manifest_path.with_extension(format!(
7168        "migration.json.tmp.{}.{}",
7169        std::process::id(),
7170        now_nanos()
7171    ));
7172    let manifest = serde_json::json!({
7173        "version": MIGRATION_MANIFEST_VERSION,
7174        "method": method,
7175        "target_generation": generation,
7176        "source_harness": source.partition.harness,
7177        "source_path": source.sqlite_path.display().to_string(),
7178        "source_generation": source.generation,
7179        "source_bytes": source.source_bytes,
7180        "source_blake3": source.source_blake3,
7181        "migrated_bytes": migrated_bytes,
7182    });
7183    {
7184        use std::io::Write as _;
7185        let mut file = std::fs::File::create(&temp_path)?;
7186        file.write_all(serde_json::to_vec_pretty(&manifest)?.as_slice())?;
7187        file.write_all(b"\n")?;
7188        file.sync_all()?;
7189    }
7190    if let Err(error) = crate::fs_lock::rename_over(&temp_path, &manifest_path) {
7191        let _ = std::fs::remove_file(&temp_path);
7192        return Err(error.into());
7193    }
7194    crate::fs_lock::sync_parent(&manifest_path);
7195    Ok(())
7196}
7197
7198fn migration_manifest_path(callgraph_dir: &Path, generation: &str) -> PathBuf {
7199    callgraph_dir.join(format!("{generation}.migration.json"))
7200}
7201
7202fn migration_generation_requires_manifest(generation: &str) -> bool {
7203    generation.contains(MIGRATION_GENERATION_TAG)
7204}
7205
7206fn migration_manifest_valid(callgraph_dir: &Path, generation: &str) -> bool {
7207    if !migration_generation_requires_manifest(generation) {
7208        return true;
7209    }
7210    let path = migration_manifest_path(callgraph_dir, generation);
7211    let Ok(bytes) = std::fs::read(path) else {
7212        return false;
7213    };
7214    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
7215        return false;
7216    };
7217    value.get("version").and_then(serde_json::Value::as_u64)
7218        == Some(MIGRATION_MANIFEST_VERSION as u64)
7219        && value
7220            .get("target_generation")
7221            .and_then(serde_json::Value::as_str)
7222            == Some(generation)
7223        && value
7224            .get("source_bytes")
7225            .and_then(serde_json::Value::as_u64)
7226            .is_some_and(|bytes| bytes > 0)
7227        && value
7228            .get("source_blake3")
7229            .and_then(serde_json::Value::as_str)
7230            .is_some_and(|hash| hash.len() == 64)
7231}
7232
7233fn cleanup_incomplete_migrations(callgraph_dir: &Path, project_key: &str) {
7234    let pointer_generation = read_pointer(callgraph_dir, project_key);
7235    if let Some(generation) = pointer_generation.as_deref() {
7236        if migration_generation_requires_manifest(generation)
7237            && !migration_manifest_valid(callgraph_dir, generation)
7238        {
7239            let path = callgraph_dir.join(generation);
7240            remove_sqlite_file_set(&path);
7241            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, generation));
7242            let _ = std::fs::remove_file(pointer_path(callgraph_dir, project_key));
7243        }
7244    }
7245
7246    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
7247        return;
7248    };
7249    for entry in entries.flatten() {
7250        let name = entry.file_name().to_string_lossy().to_string();
7251        let path = entry.path();
7252        if name.contains(".tmp.") && name.starts_with(&format!("{project_key}.g")) {
7253            let _ = std::fs::remove_file(path);
7254            continue;
7255        }
7256        if name.starts_with(&format!("{project_key}.g"))
7257            && name.ends_with(".sqlite")
7258            && name.contains(MIGRATION_GENERATION_TAG)
7259            && pointer_generation.as_deref() != Some(&name)
7260            && !migration_manifest_valid(callgraph_dir, &name)
7261        {
7262            remove_sqlite_file_set(&path);
7263            let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &name));
7264        }
7265    }
7266    crate::fs_lock::sync_parent(callgraph_dir);
7267}
7268
7269fn legacy_read_marker_label(path: &Path, generation: Option<&str>) -> String {
7270    let mut hasher = blake3::Hasher::new();
7271    hasher.update(path.to_string_lossy().as_bytes());
7272    if let Some(generation) = generation {
7273        hasher.update(generation.as_bytes());
7274    }
7275    let digest = hash_to_hex(hasher.finalize());
7276    format!("legacy-{}", &digest[..16])
7277}
7278
7279fn open_readonly_connection(path: &Path) -> Result<TrackedConnection> {
7280    let uri = sqlite_readonly_uri(path);
7281    let conn = TrackedConnection::open_with_flags(
7282        &uri,
7283        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
7284        SqliteStore::CallgraphGeneration,
7285    )?;
7286    conn.pragma_update(None, "synchronous", "NORMAL")?;
7287    conn.busy_timeout(reader_busy_timeout())?;
7288    conn.execute_batch("PRAGMA query_only=ON;")?;
7289    Ok(conn)
7290}
7291
7292fn reader_busy_timeout() -> Duration {
7293    let jitter = (now_nanos() % 500) as u64;
7294    Duration::from_millis(250 + jitter)
7295}
7296
7297fn sqlite_readonly_uri(path: &Path) -> String {
7298    let raw = path.to_string_lossy().replace('\\', "/");
7299    let encoded = percent_encode_sqlite_uri_path(&raw);
7300    if raw.starts_with('/') {
7301        format!("file://{encoded}?mode=ro")
7302    } else if raw.as_bytes().get(1) == Some(&b':') {
7303        format!("file:///{encoded}?mode=ro")
7304    } else {
7305        format!("file:{encoded}?mode=ro")
7306    }
7307}
7308
7309fn percent_encode_sqlite_uri_path(path: &str) -> String {
7310    let mut encoded = String::with_capacity(path.len());
7311    for byte in path.bytes() {
7312        match byte {
7313            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
7314                encoded.push(byte as char)
7315            }
7316            _ => encoded.push_str(&format!("%{byte:02X}")),
7317        }
7318    }
7319    encoded
7320}
7321
7322fn configure_connection(conn: &Connection) -> Result<()> {
7323    // Changing journal mode takes a database lock. Install the busy handler
7324    // first so concurrent cold-build and refresh connections wait rather than
7325    // failing immediately, especially under Windows byte-range locking.
7326    conn.busy_timeout(Duration::from_secs(5))?;
7327    conn.pragma_update(None, "journal_mode", "WAL")?;
7328    conn.pragma_update(None, "synchronous", "NORMAL")?;
7329    conn.pragma_update(
7330        None,
7331        "wal_autocheckpoint",
7332        CALLGRAPH_WAL_AUTOCHECKPOINT_PAGES,
7333    )?;
7334    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7335    Ok(())
7336}
7337
7338fn configure_build_connection(conn: &Connection) -> Result<()> {
7339    // The staging database commits independently recoverable batches. WAL keeps
7340    // those commits durable without forcing a rollback journal rewrite per batch.
7341    // Set the busy handler before WAL because selecting the journal mode itself
7342    // can contend with a connection finishing an earlier staged transaction.
7343    conn.busy_timeout(Duration::from_secs(5))?;
7344    conn.pragma_update(None, "journal_mode", "WAL")?;
7345    conn.pragma_update(None, "synchronous", "NORMAL")?;
7346    conn.pragma_update(None, "cache_size", CALLGRAPH_SQLITE_CACHE_KIB)?;
7347    Ok(())
7348}
7349
7350/// A copied migration generation may carry a WAL sidecar. Checkpoint only the
7351/// private temporary copy before publishing it; a busy reader is harmless because
7352/// the next publication or cleanup pass can retry without affecting the source.
7353fn checkpoint_sqlite_before_publication(path: &Path) {
7354    let Ok(conn) = crate::db::lifecycle::TrackedConnection::open(
7355        path,
7356        crate::db::lifecycle::SqliteStore::CallgraphGeneration,
7357    ) else {
7358        return;
7359    };
7360    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
7361    let _ = conn.busy_timeout(Duration::from_secs(5));
7362    let _ = checkpoint_wal_truncate(&conn);
7363}
7364
7365fn checkpoint_wal_truncate(conn: &Connection) -> bool {
7366    match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
7367        row.get::<_, i64>(0)
7368    }) {
7369        Ok(0) => true,
7370        Ok(_) => false,
7371        Err(rusqlite::Error::SqliteFailure(error, _))
7372            if matches!(
7373                error.code,
7374                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
7375            ) =>
7376        {
7377            false
7378        }
7379        Err(error) => {
7380            log::debug!("callgraph WAL truncate checkpoint skipped: {error}");
7381            false
7382        }
7383    }
7384}
7385
7386pub(crate) use crate::views::materialization::materialize_manifest_view_database;
7387
7388pub(crate) fn initialize_schema(conn: &Connection) -> Result<()> {
7389    conn.execute_batch(
7390        "CREATE TABLE IF NOT EXISTS files (
7391            path                TEXT PRIMARY KEY,
7392            content_hash        TEXT NOT NULL,
7393            mtime_ns            INTEGER NOT NULL,
7394            size                INTEGER NOT NULL,
7395            lang                TEXT NOT NULL,
7396            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
7397            is_public_api       INTEGER NOT NULL DEFAULT 0,
7398            surface_fingerprint TEXT NOT NULL,
7399            indexed_at          INTEGER NOT NULL
7400        );
7401
7402        CREATE TABLE IF NOT EXISTS nodes (
7403            id                         TEXT PRIMARY KEY,
7404            file_path                  TEXT NOT NULL,
7405            name                       TEXT NOT NULL,
7406            scoped_name                TEXT NOT NULL,
7407            kind                       TEXT NOT NULL,
7408            start_line                 INTEGER NOT NULL,
7409            start_col                  INTEGER NOT NULL,
7410            end_line                   INTEGER NOT NULL,
7411            end_col                    INTEGER NOT NULL,
7412            range_ordinal              INTEGER NOT NULL,
7413            signature                  TEXT,
7414            exported                   INTEGER NOT NULL,
7415            is_default_export          INTEGER NOT NULL,
7416            is_type_like               INTEGER NOT NULL,
7417            is_callgraph_entry_point   INTEGER NOT NULL,
7418            provenance                 TEXT NOT NULL,
7419            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
7420        );
7421        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7422        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7423        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7424
7425        CREATE TABLE IF NOT EXISTS refs (
7426            ref_id          TEXT PRIMARY KEY,
7427            caller_node     TEXT,
7428            caller_file     TEXT NOT NULL,
7429            kind            TEXT NOT NULL,
7430            short_name      TEXT,
7431            full_ref        TEXT,
7432            module_path     TEXT,
7433            import_kind     TEXT,
7434            local_name      TEXT,
7435            requested_name  TEXT,
7436            namespace_alias TEXT,
7437            wildcard        INTEGER NOT NULL DEFAULT 0,
7438            line            INTEGER NOT NULL,
7439            byte_start      INTEGER NOT NULL,
7440            byte_end        INTEGER NOT NULL,
7441            status          TEXT NOT NULL,
7442            target_node     TEXT,
7443            target_file     TEXT,
7444            target_symbol   TEXT,
7445            provenance      TEXT NOT NULL
7446        );
7447        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7448        CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7449        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7450        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7451        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7452
7453        CREATE TABLE IF NOT EXISTS file_dependencies (
7454            file_path   TEXT NOT NULL,
7455            dep_file    TEXT NOT NULL,
7456            PRIMARY KEY(file_path, dep_file)
7457        );
7458        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7459
7460        CREATE TABLE IF NOT EXISTS edges (
7461            edge_id       TEXT PRIMARY KEY,
7462            ref_id        TEXT NOT NULL,
7463            source_node   TEXT NOT NULL,
7464            target_node   TEXT,
7465            target_file   TEXT NOT NULL,
7466            target_symbol TEXT NOT NULL,
7467            kind          TEXT NOT NULL,
7468            line          INTEGER NOT NULL,
7469            provenance    TEXT NOT NULL
7470        );
7471        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7472        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7473        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7474        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7475
7476        CREATE TABLE IF NOT EXISTS dispatch_hints (
7477            id           TEXT PRIMARY KEY,
7478            method_name  TEXT NOT NULL,
7479            caller_node  TEXT NOT NULL,
7480            file         TEXT NOT NULL,
7481            line         INTEGER NOT NULL,
7482            byte_start   INTEGER NOT NULL,
7483            byte_end     INTEGER NOT NULL,
7484            provenance   TEXT NOT NULL
7485        );
7486        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7487        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7488
7489        CREATE TABLE IF NOT EXISTS type_ref_names (
7490            name TEXT PRIMARY KEY
7491        );
7492
7493        CREATE TABLE IF NOT EXISTS backend_file_state (
7494            backend        TEXT NOT NULL,
7495            workspace_root TEXT NOT NULL,
7496            file_path      TEXT NOT NULL,
7497            content_hash   TEXT NOT NULL,
7498            status         TEXT NOT NULL,
7499            updated_at     INTEGER NOT NULL,
7500            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
7501        );
7502        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
7503
7504        CREATE TABLE IF NOT EXISTS meta (
7505            k TEXT PRIMARY KEY,
7506            v TEXT NOT NULL
7507        );
7508
7509        -- The file walk is staged on disk so extraction can page through a
7510        -- deterministic inventory without retaining every source path in heap.
7511        CREATE TABLE IF NOT EXISTS staging_file_inventory (
7512            path TEXT PRIMARY KEY,
7513            size INTEGER NOT NULL
7514        ) WITHOUT ROWID;
7515
7516        -- Context needed only while a generation is staged. Raw refs live in
7517        -- `refs` with status `staged`; this table preserves the caller symbol
7518        -- needed to avoid inventing self edges during the later resolve pass.
7519        CREATE TABLE IF NOT EXISTS staging_ref_context (
7520            ref_id        TEXT PRIMARY KEY,
7521            caller_symbol TEXT
7522        );",
7523    )?;
7524    insert_meta(conn)?;
7525    Ok(())
7526}
7527
7528fn insert_meta(conn: &Connection) -> Result<()> {
7529    conn.execute(
7530        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
7531        params![SCHEMA_VERSION.to_string()],
7532    )?;
7533    conn.execute(
7534        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
7535        params![schema_fingerprint()],
7536    )?;
7537    conn.execute(
7538        "INSERT OR IGNORE INTO meta(k, v) VALUES('projection_write_revision', '0')",
7539        [],
7540    )?;
7541    Ok(())
7542}
7543
7544/// Return the durable revision paired atomically with graph mutations. Stores
7545/// created by older binaries lack the revision row, so callers cannot detect
7546/// in-place graph changes and must not cache their snapshots.
7547const PATH_IDENTITY_MISMATCH_META_KEY: &str = "path_identity_mismatch";
7548
7549fn record_path_identity_mismatch(conn: &Connection, error: &CallGraphStoreError) -> Result<()> {
7550    let CallGraphStoreError::PathIdentityMismatch { path, project_root } = error else {
7551        return Ok(());
7552    };
7553    conn.execute(
7554        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7555        params![
7556            PATH_IDENTITY_MISMATCH_META_KEY,
7557            format!(
7558                "callgraph_path_identity_mismatch path={} project_root={}",
7559                path.display(),
7560                project_root.display()
7561            )
7562        ],
7563    )?;
7564    Ok(())
7565}
7566
7567pub(super) fn path_identity_mismatch_reason(conn: &Connection) -> Result<Option<String>> {
7568    conn.query_row(
7569        "SELECT v FROM meta WHERE k = ?1",
7570        [PATH_IDENTITY_MISMATCH_META_KEY],
7571        |row| row.get(0),
7572    )
7573    .optional()
7574    .map_err(Into::into)
7575}
7576
7577fn projection_write_revision(conn: &Connection) -> Result<Option<u64>> {
7578    let revision: Option<String> = conn
7579        .query_row(
7580            "SELECT v FROM meta WHERE k = 'projection_write_revision'",
7581            [],
7582            |row| row.get(0),
7583        )
7584        .optional()?;
7585    revision
7586        .map(|revision| {
7587            revision.parse::<u64>().map_err(|error| {
7588                CallGraphStoreError::Unavailable(format!(
7589                    "callgraph projection write revision is invalid: {error}"
7590                ))
7591            })
7592        })
7593        .transpose()
7594}
7595
7596/// Advance the projection revision inside the graph mutation transaction so a
7597/// cached snapshot never survives an in-place refresh.
7598fn bump_projection_write_revision(tx: &Transaction<'_>) -> Result<()> {
7599    tx.execute(
7600        "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
7601         ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
7602        [],
7603    )?;
7604    #[cfg(test)]
7605    note_projection_revision_bump_for_test();
7606    Ok(())
7607}
7608
7609pub(crate) fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
7610    conn.execute(
7611        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
7612        params![if ready { "1" } else { "0" }],
7613    )?;
7614    Ok(())
7615}
7616
7617fn database_ready(conn: &Connection) -> Result<bool> {
7618    let schema_version: Option<String> = conn
7619        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
7620            row.get(0)
7621        })
7622        .optional()?;
7623    let fingerprint: Option<String> = conn
7624        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
7625            row.get(0)
7626        })
7627        .optional()?;
7628    let ready: Option<String> = conn
7629        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
7630        .optional()?;
7631
7632    let expected_schema = SCHEMA_VERSION.to_string();
7633    let expected_fingerprint = schema_fingerprint();
7634    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
7635        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
7636        && ready.as_deref() == Some("1"))
7637}
7638
7639fn ensure_database_ready(conn: &Connection) -> Result<()> {
7640    if database_ready(conn)? {
7641        Ok(())
7642    } else {
7643        Err(CallGraphStoreError::Unavailable(
7644            "database is missing, stale, or mid-build".to_string(),
7645        ))
7646    }
7647}
7648
7649fn schema_fingerprint() -> String {
7650    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
7651    // edge sources, broader call extraction) even if the table SHAPE is
7652    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
7653    // Rust scoped aliases, inline modules, reexports, and turbofish calls now add edges.
7654    let input =
7655        format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v9-rust-resolver-batch");
7656    hash_to_hex(blake3::hash(input.as_bytes()))
7657}
7658
7659fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
7660    tx.execute_batch(
7661        "DELETE FROM staging_ref_context;
7662         DELETE FROM edges;
7663         DELETE FROM file_dependencies;
7664         DELETE FROM refs;
7665         DELETE FROM dispatch_hints;
7666         DELETE FROM type_ref_names;
7667         DELETE FROM backend_file_state;
7668         DELETE FROM nodes;
7669         DELETE FROM files;",
7670    )?;
7671    Ok(())
7672}
7673
7674fn staged_build_phase(conn: &Connection) -> Result<Option<String>> {
7675    conn.query_row(
7676        "SELECT v FROM meta WHERE k = ?1",
7677        params![STAGED_BUILD_PHASE],
7678        |row| row.get(0),
7679    )
7680    .optional()
7681    .map_err(Into::into)
7682}
7683
7684fn staged_u64(conn: &Connection, key: &str) -> Result<u64> {
7685    let value = staged_string(conn, key)?;
7686    Ok(value.and_then(|value| value.parse().ok()).unwrap_or(0))
7687}
7688
7689fn staged_string(conn: &Connection, key: &str) -> Result<Option<String>> {
7690    conn.query_row("SELECT v FROM meta WHERE k = ?1", params![key], |row| {
7691        row.get::<_, String>(0)
7692    })
7693    .optional()
7694    .map_err(Into::into)
7695}
7696
7697fn set_staged_build_phase(tx: &Transaction<'_>, phase: &str) -> Result<()> {
7698    tx.execute(
7699        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7700        params![STAGED_BUILD_PHASE, phase],
7701    )?;
7702    Ok(())
7703}
7704
7705fn set_staged_u64(tx: &Transaction<'_>, key: &str, value: u64) -> Result<()> {
7706    set_staged_string(tx, key, &value.to_string())
7707}
7708
7709fn set_staged_string(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
7710    tx.execute(
7711        "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7712        params![key, value],
7713    )?;
7714    Ok(())
7715}
7716
7717/// The extract rows and this counter update share a SQLite transaction. This is
7718/// intentionally not inferred from file/page growth: rollback removes both the
7719/// rows and the claimed credit, while page reuse cannot fabricate credit.
7720fn increment_staged_extracted_bytes(tx: &Transaction<'_>, bytes: u64) -> Result<()> {
7721    tx.execute(
7722        "INSERT INTO meta(k, v) VALUES(?1, ?2)
7723         ON CONFLICT(k) DO UPDATE SET v = CAST(meta.v AS INTEGER) + excluded.v",
7724        params![STAGED_COMMITTED_EXTRACTED_BYTES, bytes.to_string()],
7725    )?;
7726    Ok(())
7727}
7728
7729fn staged_content_matches(conn: &Connection, project_root: &Path, path: &Path) -> Result<bool> {
7730    let Ok(source) = std::fs::read_to_string(path) else {
7731        return Ok(false);
7732    };
7733    let Ok(freshness) = collect_source_freshness(path, &source) else {
7734        return Ok(false);
7735    };
7736    let rel_path = relative_path(project_root, path);
7737    let staged_hash = conn
7738        .query_row(
7739            "SELECT content_hash FROM files WHERE path = ?1",
7740            params![rel_path],
7741            |row| row.get::<_, String>(0),
7742        )
7743        .optional()?;
7744    Ok(staged_hash.as_deref() == Some(hash_to_hex(freshness.content_hash).as_str()))
7745}
7746
7747fn delete_staged_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
7748    tx.execute(
7749        "DELETE FROM staging_ref_context
7750         WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)",
7751        params![rel_path],
7752    )?;
7753    delete_file_rows(tx, rel_path)
7754}
7755
7756fn prune_staged_files_not_in_inventory(conn: &mut Connection) -> Result<()> {
7757    loop {
7758        let removed = {
7759            let mut statement = conn.prepare(
7760                "SELECT path
7761                 FROM files
7762                 WHERE NOT EXISTS (
7763                     SELECT 1 FROM staging_file_inventory inventory
7764                     WHERE inventory.path = files.path
7765                 )
7766                 ORDER BY path
7767                 LIMIT ?1",
7768            )?;
7769            let paths = statement
7770                .query_map(params![COLD_BUILD_EXTRACT_BATCH_FILES as i64], |row| {
7771                    row.get::<_, String>(0)
7772                })?
7773                .collect::<std::result::Result<Vec<_>, _>>()?;
7774            paths
7775        };
7776        if removed.is_empty() {
7777            return Ok(());
7778        }
7779        let tx = conn.transaction()?;
7780        for path in removed {
7781            delete_staged_file_rows(&tx, &path)?;
7782        }
7783        tx.commit()?;
7784    }
7785}
7786
7787struct StagedFileBatch {
7788    paths: Vec<PathBuf>,
7789    last_path: String,
7790}
7791
7792fn load_staged_file_batch(
7793    conn: &Connection,
7794    project_root: &Path,
7795    after_path: &str,
7796    max_files: usize,
7797    max_bytes: u64,
7798) -> Result<Option<StagedFileBatch>> {
7799    let mut statement = conn.prepare(
7800        "SELECT path, size
7801         FROM staging_file_inventory
7802         WHERE path > ?1
7803         ORDER BY path
7804         LIMIT ?2",
7805    )?;
7806    let mut rows = statement.query(params![after_path, max_files.max(1) as i64])?;
7807    let mut paths = Vec::with_capacity(max_files.max(1));
7808    let mut last_path = String::new();
7809    let mut batch_bytes = 0u64;
7810    while let Some(row) = rows.next()? {
7811        let rel_path = row.get::<_, String>(0)?;
7812        let size = row.get::<_, i64>(1)?.max(0) as u64;
7813        if !paths.is_empty() && batch_bytes.saturating_add(size) > max_bytes {
7814            break;
7815        }
7816        batch_bytes = batch_bytes.saturating_add(size);
7817        last_path.clone_from(&rel_path);
7818        paths.push(project_root.join(rel_path));
7819    }
7820    if paths.is_empty() {
7821        Ok(None)
7822    } else {
7823        Ok(Some(StagedFileBatch { paths, last_path }))
7824    }
7825}
7826
7827fn staged_corpus_fingerprint(conn: &Connection, project_root: &Path) -> Result<String> {
7828    let mut statement = conn.prepare("SELECT path FROM staging_file_inventory ORDER BY path")?;
7829    let mut rows = statement.query([])?;
7830    let mut fingerprint = CorpusFingerprint::default();
7831    while let Some(row) = rows.next()? {
7832        let rel_path = row.get::<_, String>(0)?;
7833        fingerprint.add_path(project_root, &project_root.join(rel_path));
7834    }
7835    Ok(fingerprint.finish(project_root))
7836}
7837
7838fn load_staged_ref_window(
7839    conn: &Connection,
7840    after_rowid: u64,
7841    limit: usize,
7842) -> Result<Vec<StagedRef>> {
7843    let mut statement = conn.prepare(
7844        "SELECT refs.rowid, refs.ref_id, refs.caller_node, refs.caller_file, refs.kind,
7845                refs.short_name, refs.full_ref, refs.module_path, refs.import_kind,
7846                refs.local_name, refs.requested_name, refs.namespace_alias, refs.wildcard,
7847                refs.line, refs.byte_start, refs.byte_end, staging_ref_context.caller_symbol
7848         FROM refs
7849         LEFT JOIN staging_ref_context ON staging_ref_context.ref_id = refs.ref_id
7850         WHERE refs.status = 'staged' AND refs.rowid > ?1
7851         ORDER BY refs.rowid
7852         LIMIT ?2",
7853    )?;
7854    let rows = statement.query_map(params![after_rowid as i64, limit as i64], |row| {
7855        Ok(StagedRef {
7856            rowid: row.get::<_, i64>(0)? as u64,
7857            raw: RawRef {
7858                ref_id: row.get(1)?,
7859                caller_node: row.get(2)?,
7860                caller_file: row.get(3)?,
7861                kind: row.get(4)?,
7862                short_name: row.get(5)?,
7863                full_ref: row.get(6)?,
7864                module_path: row.get(7)?,
7865                import_kind: row.get(8)?,
7866                local_name: row.get(9)?,
7867                requested_name: row.get(10)?,
7868                namespace_alias: row.get(11)?,
7869                wildcard: row.get::<_, i64>(12)? != 0,
7870                line: row.get::<_, i64>(13)? as u32,
7871                byte_start: row.get::<_, i64>(14)? as usize,
7872                byte_end: row.get::<_, i64>(15)? as usize,
7873                caller_symbol: row.get(16)?,
7874                dependencies: BTreeSet::new(),
7875            },
7876        })
7877    })?;
7878    let mut refs = rows.collect::<std::result::Result<Vec<_>, _>>()?;
7879    drop(statement);
7880
7881    let mut dependencies = HashMap::<String, BTreeSet<String>>::new();
7882    let mut dependency_statement = conn
7883        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
7884    for raw in refs.iter_mut().map(|entry| &mut entry.raw) {
7885        if !dependencies.contains_key(&raw.caller_file) {
7886            let rows =
7887                dependency_statement.query_map(params![raw.caller_file], |row| row.get(0))?;
7888            let values = rows.collect::<std::result::Result<BTreeSet<_>, _>>()?;
7889            dependencies.insert(raw.caller_file.clone(), values);
7890        }
7891        raw.dependencies = dependencies
7892            .get(&raw.caller_file)
7893            .cloned()
7894            .unwrap_or_default();
7895    }
7896    Ok(refs)
7897}
7898
7899fn unresolved_staged_ref(raw: RawRef) -> ResolvedRef {
7900    ResolvedRef {
7901        dependencies: raw.dependencies.clone(),
7902        raw,
7903        status: "unresolved".to_string(),
7904        target_node: None,
7905        target_file: None,
7906        target_symbol: None,
7907        edge: None,
7908    }
7909}
7910
7911fn query_count(conn: &Connection, query: &str) -> Result<u64> {
7912    conn.query_row(query, [], |row| row.get::<_, i64>(0))
7913        .map(|count| count.max(0) as u64)
7914        .map_err(Into::into)
7915}
7916
7917fn cold_build_stats_from_connection(conn: &Connection, started: Instant) -> Result<ColdBuildStats> {
7918    let files = query_count(conn, "SELECT COUNT(*) FROM files")? as usize;
7919    let nodes = query_count(conn, "SELECT COUNT(*) FROM nodes")? as usize;
7920    let refs = query_count(conn, "SELECT COUNT(*) FROM refs")? as usize;
7921    let edges = query_count(conn, "SELECT COUNT(*) FROM edges")? as usize;
7922    let failed_files = staged_failed_files(conn)?;
7923    let elapsed_ms = started.elapsed().as_millis();
7924    crate::slog_info!(
7925        "perf callgraph_store bounded cold_build: files={} nodes={} refs={} edges={} committed_extracted_bytes={} ms={}",
7926        files,
7927        nodes,
7928        refs,
7929        edges,
7930        staged_u64(conn, STAGED_COMMITTED_EXTRACTED_BYTES)?,
7931        elapsed_ms
7932    );
7933    Ok(ColdBuildStats {
7934        files,
7935        nodes,
7936        refs,
7937        edges,
7938        failed_files,
7939        elapsed_ms,
7940    })
7941}
7942
7943fn staged_failed_files(conn: &Connection) -> Result<Vec<String>> {
7944    let mut statement = conn.prepare(
7945        "SELECT DISTINCT file_path FROM backend_file_state WHERE status = 'stale' ORDER BY file_path",
7946    )?;
7947    let rows = statement.query_map([], |row| row.get(0))?;
7948    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
7949}
7950
7951fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7952    tx.execute_batch(
7953        "DROP INDEX IF EXISTS idx_nodes_file;
7954         DROP INDEX IF EXISTS idx_nodes_name;
7955         DROP INDEX IF EXISTS idx_nodes_scoped;
7956         DROP INDEX IF EXISTS idx_refs_short_name;
7957         DROP INDEX IF EXISTS idx_refs_kind_caller_file;
7958         DROP INDEX IF EXISTS idx_refs_caller_file;
7959         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
7960         DROP INDEX IF EXISTS idx_refs_target_file;
7961         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
7962         DROP INDEX IF EXISTS idx_edges_source_kind;
7963         DROP INDEX IF EXISTS idx_edges_target_kind;
7964         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
7965         DROP INDEX IF EXISTS idx_edges_ref_id;
7966         DROP INDEX IF EXISTS idx_dispatch_hints_method;
7967         DROP INDEX IF EXISTS idx_dispatch_hints_file;
7968         DROP INDEX IF EXISTS idx_backend_file_state_file;",
7969    )?;
7970    Ok(())
7971}
7972
7973fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
7974    tx.execute_batch(
7975        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
7976         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
7977         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
7978         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
7979         CREATE INDEX IF NOT EXISTS idx_refs_kind_caller_file ON refs(kind, caller_file);
7980         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
7981         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
7982         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
7983         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
7984         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
7985         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
7986         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
7987         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
7988         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
7989         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_file ON dispatch_hints(file);
7990         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
7991    )?;
7992    Ok(())
7993}
7994
7995const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
7996    ("files", "path"),
7997    ("nodes", "file_path"),
7998    ("refs", "caller_file"),
7999    ("refs", "target_file"),
8000    ("file_dependencies", "file_path"),
8001    ("file_dependencies", "dep_file"),
8002    ("edges", "target_file"),
8003    ("dispatch_hints", "file"),
8004    ("backend_file_state", "file_path"),
8005];
8006
8007/// Reconcile `backend_file_state.workspace_root` when the opener's project root
8008/// differs from what is stored. The store key is the git-root commit hash, so
8009/// multiple live checkouts/clones share one on-disk generation.
8010///
8011/// Cheap in-place re-root is only safe when every previously stored root path is
8012/// gone from disk (true move/rename). If any stale root still exists, another
8013/// clone is still alive and rewriting metadata would ping-pong relative rows
8014/// between trees (possibly on different branches). We then return
8015/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
8016/// opener. That can make each clone rebuild on open when they alternate — bounded
8017/// by open frequency — but each rebuild is correct for its opener, unlike silent
8018/// cross-clone corruption.
8019fn reconcile_workspace_roots(
8020    conn: &mut Connection,
8021    project_root: &Path,
8022    allow_repair: bool,
8023) -> Result<OpenRootRepair> {
8024    let roots = stored_workspace_roots(conn)?;
8025    let current_root = project_root.display().to_string();
8026    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
8027        return Ok(OpenRootRepair::None);
8028    }
8029
8030    if let Some(sample) = sample_absolute_data_path(conn)? {
8031        return Ok(OpenRootRepair::NeedsRebuild {
8032            previous_roots: roots,
8033            current_root,
8034            reason: format!("absolute store data path row {sample}"),
8035        });
8036    }
8037
8038    for stored_root in roots.iter() {
8039        if stored_root == &current_root {
8040            continue;
8041        }
8042        if Path::new(stored_root).exists() {
8043            let reason = format!(
8044                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
8045            );
8046            return Ok(OpenRootRepair::NeedsRebuild {
8047                previous_roots: roots,
8048                current_root,
8049                reason,
8050            });
8051        }
8052    }
8053
8054    if !allow_repair {
8055        return Ok(OpenRootRepair::NeedsRebuild {
8056            previous_roots: roots,
8057            current_root,
8058            reason: "workspace root metadata requires deferred repair".to_string(),
8059        });
8060    }
8061
8062    publish_if_current(|| {
8063        let tx = conn.transaction()?;
8064        tx.execute(
8065            "UPDATE OR IGNORE backend_file_state
8066             SET workspace_root = ?1
8067             WHERE workspace_root <> ?1",
8068            params![&current_root],
8069        )?;
8070        tx.execute(
8071            "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
8072            params![&current_root],
8073        )?;
8074        tx.commit()?;
8075        Ok(())
8076    })?;
8077
8078    crate::slog_info!(
8079        "callgraph store re-rooted from {} to {}",
8080        roots.join(", "),
8081        current_root
8082    );
8083    Ok(OpenRootRepair::ReRooted)
8084}
8085
8086fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
8087    let mut stmt = conn.prepare(
8088        "SELECT DISTINCT workspace_root
8089         FROM backend_file_state
8090         ORDER BY workspace_root",
8091    )?;
8092    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
8093    rows.collect::<std::result::Result<Vec<_>, _>>()
8094        .map_err(Into::into)
8095}
8096
8097fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
8098    for (table, column) in STORE_DATA_PATH_COLUMNS {
8099        let sql = format!(
8100            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
8101        );
8102        let mut stmt = conn.prepare(&sql)?;
8103        let mut rows = stmt.query([])?;
8104        while let Some(row) = rows.next()? {
8105            let value: String = row.get(0)?;
8106            if stored_path_is_absolute(&value) {
8107                return Ok(Some(format!("{table}.{column}={value}")));
8108            }
8109        }
8110    }
8111    Ok(None)
8112}
8113
8114fn stored_path_is_absolute(value: &str) -> bool {
8115    if value.is_empty() {
8116        return false;
8117    }
8118    if Path::new(value).is_absolute() || value.starts_with('/') {
8119        return true;
8120    }
8121    let bytes = value.as_bytes();
8122    if bytes.len() >= 3
8123        && bytes[1] == b':'
8124        && (bytes[2] == b'/' || bytes[2] == b'\\')
8125        && bytes[0].is_ascii_alphabetic()
8126    {
8127        return true;
8128    }
8129    value.starts_with("\\\\") || value.starts_with("//")
8130}
8131
8132fn log_root_repair_rebuild(repair: &OpenRootRepair) {
8133    if let OpenRootRepair::NeedsRebuild {
8134        previous_roots,
8135        current_root,
8136        reason,
8137    } = repair
8138    {
8139        crate::slog_info!(
8140            "callgraph cold-build decision: reason=re-rooting refused; from={}; to={}; detail={}",
8141            previous_roots.join(", "),
8142            current_root,
8143            reason
8144        );
8145    }
8146}
8147
8148/// Nanosecond clock used to make temp/generation file names unique.
8149fn now_nanos() -> u128 {
8150    SystemTime::now()
8151        .duration_since(UNIX_EPOCH)
8152        .unwrap_or(Duration::ZERO)
8153        .as_nanos()
8154}
8155
8156/// The pointer file `<dir>/<key>.current`. Its single line names the current
8157/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
8158/// can always be atomically replaced via rename even on Windows — Rust opens
8159/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
8160fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8161    callgraph_dir.join(format!("{project_key}.current"))
8162}
8163
8164/// The legacy single-file DB path used before the generation scheme. Still read
8165/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
8166/// build publishes a generation.
8167fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
8168    callgraph_dir.join(format!("{project_key}.sqlite"))
8169}
8170
8171/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
8172/// cold build writes a brand-new generation file, so publishing NEVER replaces
8173/// a file another process holds open (the root Windows fix).
8174fn generation_file_name(project_key: &str) -> String {
8175    format!(
8176        "{project_key}.g{}.{}.sqlite",
8177        now_nanos(),
8178        std::process::id()
8179    )
8180}
8181
8182/// Read the pointer; returns the generation file name if present and non-empty.
8183fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
8184    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
8185    let name = text.trim();
8186    if name.is_empty() {
8187        None
8188    } else {
8189        Some(name.to_string())
8190    }
8191}
8192
8193/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
8194/// `ready` flag). Uses a throwaway read-only connection.
8195fn db_path_ready(path: &Path) -> bool {
8196    (|| -> Result<bool> {
8197        let conn = open_readonly_connection(path)?;
8198        database_ready(&conn)
8199    })()
8200    .unwrap_or(false)
8201}
8202
8203/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
8204/// where `generation` is `Some(name)` for a pointer-published generation or
8205/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
8206/// published (caller treats that as "needs cold build").
8207///
8208/// Handles the GC race (the pointer names a generation that was just deleted) by
8209/// re-reading the pointer and retrying a few times.
8210fn resolve_ready_target(
8211    callgraph_dir: &Path,
8212    project_key: &str,
8213) -> Option<(PathBuf, Option<String>)> {
8214    for _ in 0..5 {
8215        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
8216            let gen_path = callgraph_dir.join(&generation);
8217            if gen_path.is_file() {
8218                return (migration_manifest_valid(callgraph_dir, &generation)
8219                    && db_path_ready(&gen_path))
8220                .then_some((gen_path, Some(generation)));
8221            }
8222            // Pointer names a missing generation (a GC/publish race): re-read the
8223            // pointer and retry rather than failing the reader.
8224            std::thread::sleep(Duration::from_millis(5));
8225            continue;
8226        }
8227        // No pointer: fall back to the legacy single-file DB if it is ready.
8228        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
8229        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
8230    }
8231    None
8232}
8233
8234/// Atomically publish `generation` as the current store by flipping the pointer
8235/// file. Writes a temp file, fsyncs, then renames over the pointer — never
8236/// replacing an open DB file, so it succeeds cross-platform.
8237fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
8238    let pointer = pointer_path(callgraph_dir, project_key);
8239    let tmp = callgraph_dir.join(format!(
8240        "{project_key}.current.tmp.{}.{}",
8241        std::process::id(),
8242        now_nanos()
8243    ));
8244    {
8245        use std::io::Write as _;
8246        let mut file = std::fs::File::create(&tmp)?;
8247        file.write_all(generation.as_bytes())?;
8248        file.write_all(b"\n")?;
8249        file.sync_all()?;
8250    }
8251    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
8252        let _ = std::fs::remove_file(&tmp);
8253        return Err(error.into());
8254    }
8255    crate::fs_lock::sync_parent(&pointer);
8256    Ok(())
8257}
8258
8259#[derive(Clone, Debug)]
8260struct GenerationGcCandidate {
8261    name: String,
8262    path: PathBuf,
8263    modified: SystemTime,
8264}
8265
8266/// Best-effort GC of superseded generation files. The current pointer target and
8267/// newest previous generation are always retained. Older generations are removed
8268/// when they have no protected read marker, or after the absolute retention TTL
8269/// even if an ultra-stale marker remains. Stale marker files are reclaimed during
8270/// every sweep so dead-PID and expired cross-host readers do not pin disk forever.
8271fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
8272    let temp_grace = Duration::from_secs(60);
8273    let now = SystemTime::now();
8274    let pointer_current =
8275        read_pointer(callgraph_dir, project_key).unwrap_or_else(|| current.to_string());
8276    let gen_prefix = format!("{project_key}.g");
8277    let tmp_prefixes = [
8278        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
8279        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
8280        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
8281    ];
8282    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8283        return;
8284    };
8285    let mut gens: Vec<GenerationGcCandidate> = Vec::new();
8286    for entry in entries.flatten() {
8287        let name = entry.file_name();
8288        let name = name.to_string_lossy().to_string();
8289        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
8290        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= temp_grace;
8291
8292        // Orphaned temp files from a crashed build/publish: remove once aged out.
8293        if name.contains(".tmp.") {
8294            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
8295                let _ = std::fs::remove_file(entry.path());
8296            }
8297            continue;
8298        }
8299
8300        // Superseded legacy single-file DB: best-effort delete once a generation
8301        // is published (ignored if another process still holds it open).
8302        if name == format!("{project_key}.sqlite") {
8303            remove_sqlite_file_set(&entry.path());
8304            continue;
8305        }
8306
8307        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") {
8308            gens.push(GenerationGcCandidate {
8309                name,
8310                path: entry.path(),
8311                modified: mtime,
8312            });
8313        }
8314    }
8315
8316    let mut superseded = gens
8317        .iter()
8318        .filter(|generation| generation.name != pointer_current)
8319        .collect::<Vec<_>>();
8320    superseded.sort_by(|left, right| {
8321        right
8322            .modified
8323            .cmp(&left.modified)
8324            .then_with(|| right.name.cmp(&left.name))
8325    });
8326    let previous = superseded.first().map(|generation| generation.name.clone());
8327
8328    for generation in gens {
8329        let sweep = crate::root_cache::sweep_read_markers(callgraph_dir, &generation.name);
8330        if generation.name == pointer_current
8331            || Some(generation.name.as_str()) == previous.as_deref()
8332        {
8333            continue;
8334        }
8335
8336        let age = now
8337            .duration_since(generation.modified)
8338            .unwrap_or(Duration::ZERO);
8339        if sweep.protected && age < MARKED_GENERATION_RETENTION_TTL {
8340            continue;
8341        }
8342
8343        remove_sqlite_file_set(&generation.path);
8344        let _ = std::fs::remove_file(migration_manifest_path(callgraph_dir, &generation.name));
8345        let _ = std::fs::remove_dir_all(crate::root_cache::read_marker_dir(
8346            callgraph_dir,
8347            &generation.name,
8348        ));
8349    }
8350}
8351
8352fn remove_sqlite_file_set(path: &Path) {
8353    let _ = std::fs::remove_file(path);
8354    remove_sqlite_sidecars(path);
8355}
8356
8357fn remove_sqlite_sidecars(path: &Path) {
8358    let path_text = path.to_string_lossy();
8359    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
8360    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
8361    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
8362}
8363
8364#[derive(Clone, Copy, Debug, Default)]
8365struct CallgraphRootSweepSummary {
8366    scanned: usize,
8367    removed: usize,
8368    bytes: u64,
8369    generation_gc: usize,
8370    skipped_memo: usize,
8371    skipped_derived: usize,
8372    skipped_fresh: usize,
8373    skipped_reader: usize,
8374    skipped_lease: usize,
8375    skipped_unreadable: usize,
8376    budget_exhausted: bool,
8377}
8378
8379#[derive(Clone, Copy, Debug, Default)]
8380struct CallgraphRootFileStats {
8381    newest: Option<SystemTime>,
8382    bytes: u64,
8383}
8384
8385enum CallgraphRootWalk {
8386    Complete(CallgraphRootFileStats),
8387    BudgetExceeded,
8388    Failed,
8389}
8390
8391enum CallgraphRootCandidate {
8392    Removed { bytes: u64 },
8393    GenerationGc,
8394    SkippedMemo,
8395    SkippedDerived,
8396    SkippedFresh,
8397    SkippedReader,
8398    SkippedLease,
8399    SkippedUnreadable,
8400    BudgetExceeded,
8401}
8402
8403/// Sweep root-keyed callgraph directories that were detached when cache-key
8404/// eviction forgot a checkout. Generation GC alone only runs while a root
8405/// publishes, so inactive roots otherwise keep every obsolete generation forever.
8406///
8407/// The pass reuses the index-cache liveness boundary and takes each directory's
8408/// writer lease before mutating it. A current memo entry remains eligible only for
8409/// superseded-generation GC; an absent entry is eligible for whole-directory
8410/// deletion after the conservative age threshold.
8411fn sweep_orphaned_callgraph_root_dirs(callgraph_dir: &Path) {
8412    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8413        return;
8414    };
8415    let root_dir = storage_root.join(crate::root_cache::RootCacheDomain::Callgraph.as_str());
8416    let memo_keys = match crate::search_index::referenced_artifact_cache_keys(&storage_root) {
8417        Ok(keys) => keys,
8418        Err(error) => {
8419            crate::slog_warn!(
8420                "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={}",
8421                root_dir.display(),
8422                error
8423            );
8424            return;
8425        }
8426    };
8427    let derived_keys = crate::search_index::derived_artifact_cache_keys();
8428    let summary = sweep_callgraph_root_dirs_with_limits(
8429        &root_dir,
8430        &memo_keys,
8431        &derived_keys,
8432        CALLGRAPH_ROOT_SWEEP_BUDGET,
8433        CALLGRAPH_ROOT_SWEEP_LIMIT,
8434    );
8435    crate::slog_info!(
8436        "callgraph root sweep root={} scanned={} removed={} bytes={} generation_gc={} skipped_memo={} skipped_derived={} skipped_fresh={} skipped_reader={} skipped_lease={} skipped_unreadable={} budget_exhausted={}",
8437        root_dir.display(),
8438        summary.scanned,
8439        summary.removed,
8440        summary.bytes,
8441        summary.generation_gc,
8442        summary.skipped_memo,
8443        summary.skipped_derived,
8444        summary.skipped_fresh,
8445        summary.skipped_reader,
8446        summary.skipped_lease,
8447        summary.skipped_unreadable,
8448        summary.budget_exhausted
8449    );
8450}
8451
8452fn sweep_callgraph_root_dirs_with_limits(
8453    root_dir: &Path,
8454    memo_keys: &HashSet<String>,
8455    derived_keys: &HashSet<String>,
8456    wall_clock_budget: Duration,
8457    entry_limit: usize,
8458) -> CallgraphRootSweepSummary {
8459    let started = Instant::now();
8460    let deadline = started + wall_clock_budget;
8461    let boundary = match crate::walk_boundary::DeviceBoundary::for_root(root_dir) {
8462        Ok(boundary) => boundary,
8463        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8464            return CallgraphRootSweepSummary::default();
8465        }
8466        Err(error) => {
8467            crate::slog_warn!(
8468                "cannot establish filesystem boundary for callgraph root sweep {}: {}",
8469                root_dir.display(),
8470                error
8471            );
8472            return CallgraphRootSweepSummary {
8473                skipped_unreadable: 1,
8474                ..CallgraphRootSweepSummary::default()
8475            };
8476        }
8477    };
8478    let mut entries = match std::fs::read_dir(root_dir) {
8479        Ok(entries) => entries
8480            .filter_map(|entry| entry.ok())
8481            .filter_map(|entry| {
8482                let name = entry.file_name().to_string_lossy().into_owned();
8483                entry
8484                    .file_type()
8485                    .ok()
8486                    .filter(|file_type| file_type.is_dir() && artifact_key_looks_valid(&name))
8487                    .map(|_| (name, entry.path()))
8488            })
8489            .collect::<Vec<_>>(),
8490        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8491        Err(error) => {
8492            crate::slog_warn!(
8493                "cannot read callgraph root sweep directory {}: {}",
8494                root_dir.display(),
8495                error
8496            );
8497            return CallgraphRootSweepSummary {
8498                skipped_unreadable: 1,
8499                ..CallgraphRootSweepSummary::default()
8500            };
8501        }
8502    };
8503    entries.sort_by(|left, right| left.0.cmp(&right.0));
8504
8505    let cursor_store = CALLGRAPH_ROOT_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
8506    let last_name = cursor_store
8507        .lock()
8508        .ok()
8509        .and_then(|cursors| cursors.get(root_dir).cloned());
8510    if let Some(start) = last_name
8511        .as_deref()
8512        .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
8513    {
8514        entries.rotate_left(start);
8515    }
8516
8517    let mut summary = CallgraphRootSweepSummary::default();
8518    let mut cursor_name = last_name;
8519    for (processed, (key, cache_dir)) in entries.into_iter().enumerate() {
8520        if processed >= entry_limit || Instant::now() >= deadline {
8521            summary.budget_exhausted = true;
8522            break;
8523        }
8524        summary.scanned += 1;
8525        cursor_name = Some(key.clone());
8526        match callgraph_root_candidate(
8527            &cache_dir,
8528            &key,
8529            memo_keys.contains(&key),
8530            derived_keys.contains(&key),
8531            &boundary,
8532            deadline,
8533        ) {
8534            CallgraphRootCandidate::Removed { bytes } => {
8535                summary.removed += 1;
8536                summary.bytes = summary.bytes.saturating_add(bytes);
8537            }
8538            CallgraphRootCandidate::GenerationGc => summary.generation_gc += 1,
8539            CallgraphRootCandidate::SkippedMemo => summary.skipped_memo += 1,
8540            CallgraphRootCandidate::SkippedDerived => summary.skipped_derived += 1,
8541            CallgraphRootCandidate::SkippedFresh => summary.skipped_fresh += 1,
8542            CallgraphRootCandidate::SkippedReader => summary.skipped_reader += 1,
8543            CallgraphRootCandidate::SkippedLease => summary.skipped_lease += 1,
8544            CallgraphRootCandidate::SkippedUnreadable => summary.skipped_unreadable += 1,
8545            CallgraphRootCandidate::BudgetExceeded => {
8546                summary.budget_exhausted = true;
8547                break;
8548            }
8549        }
8550    }
8551
8552    if let Ok(mut cursors) = cursor_store.lock() {
8553        if summary.budget_exhausted {
8554            if let Some(cursor_name) = cursor_name {
8555                cursors.insert(root_dir.to_path_buf(), cursor_name);
8556            }
8557        } else {
8558            cursors.remove(root_dir);
8559        }
8560    }
8561    if summary.removed > 0 {
8562        crate::fs_lock::sync_parent(root_dir);
8563    }
8564    summary
8565}
8566
8567fn callgraph_root_candidate(
8568    cache_dir: &Path,
8569    project_key: &str,
8570    memo_referenced: bool,
8571    derived_in_process: bool,
8572    boundary: &crate::walk_boundary::DeviceBoundary,
8573    deadline: Instant,
8574) -> CallgraphRootCandidate {
8575    if !boundary.should_descend(cache_dir).unwrap_or(false) {
8576        return CallgraphRootCandidate::SkippedUnreadable;
8577    }
8578    if memo_referenced || derived_in_process {
8579        return sweep_live_callgraph_root_generations(
8580            cache_dir,
8581            project_key,
8582            memo_referenced,
8583            boundary,
8584            deadline,
8585        );
8586    }
8587
8588    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8589        CallgraphRootWalk::Complete(stats) => stats,
8590        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8591        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8592    };
8593    let Some(newest) = stats.newest else {
8594        return CallgraphRootCandidate::SkippedUnreadable;
8595    };
8596    if SystemTime::now()
8597        .duration_since(newest)
8598        .unwrap_or(Duration::ZERO)
8599        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8600    {
8601        return CallgraphRootCandidate::SkippedFresh;
8602    }
8603
8604    // Keep the writer lease held through deletion. A concurrent publisher either
8605    // owns it first (and this pass skips) or starts after this directory is gone.
8606    let _writer_lease = match crate::fs_lock::try_acquire(
8607        &crate::root_cache::writer_lease_path(cache_dir),
8608        Duration::ZERO,
8609    ) {
8610        Ok(lease) => lease,
8611        Err(_) => return CallgraphRootCandidate::SkippedLease,
8612    };
8613    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8614        return CallgraphRootCandidate::SkippedReader;
8615    }
8616
8617    match std::fs::remove_dir_all(cache_dir) {
8618        Ok(()) => {
8619            crate::slog_info!(
8620                "callgraph root sweep reaped dir={} key={} bytes={}",
8621                cache_dir.display(),
8622                project_key,
8623                stats.bytes
8624            );
8625            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8626        }
8627        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !cache_dir.exists() => {
8628            crate::slog_info!(
8629                "callgraph root sweep reaped dir={} key={} bytes={}",
8630                cache_dir.display(),
8631                project_key,
8632                stats.bytes
8633            );
8634            CallgraphRootCandidate::Removed { bytes: stats.bytes }
8635        }
8636        Err(_) => CallgraphRootCandidate::SkippedUnreadable,
8637    }
8638}
8639
8640fn sweep_live_callgraph_root_generations(
8641    cache_dir: &Path,
8642    project_key: &str,
8643    memo_referenced: bool,
8644    boundary: &crate::walk_boundary::DeviceBoundary,
8645    deadline: Instant,
8646) -> CallgraphRootCandidate {
8647    if Instant::now() >= deadline {
8648        return CallgraphRootCandidate::BudgetExceeded;
8649    }
8650    let stats = match callgraph_root_file_stats(cache_dir, boundary, deadline) {
8651        CallgraphRootWalk::Complete(stats) => stats,
8652        CallgraphRootWalk::BudgetExceeded => return CallgraphRootCandidate::BudgetExceeded,
8653        CallgraphRootWalk::Failed => return CallgraphRootCandidate::SkippedUnreadable,
8654    };
8655    let Some(newest) = stats.newest else {
8656        return CallgraphRootCandidate::SkippedUnreadable;
8657    };
8658    if SystemTime::now()
8659        .duration_since(newest)
8660        .unwrap_or(Duration::ZERO)
8661        < CALLGRAPH_ROOT_ORPHAN_MIN_AGE
8662    {
8663        return CallgraphRootCandidate::SkippedFresh;
8664    }
8665    let _writer_lease = match crate::fs_lock::try_acquire(
8666        &crate::root_cache::writer_lease_path(cache_dir),
8667        Duration::ZERO,
8668    ) {
8669        Ok(lease) => lease,
8670        Err(_) => return CallgraphRootCandidate::SkippedLease,
8671    };
8672    if crate::root_cache::sweep_all_read_markers(cache_dir).protected {
8673        return CallgraphRootCandidate::SkippedReader;
8674    }
8675    if let Some(current) = read_pointer(cache_dir, project_key) {
8676        gc_old_generations(cache_dir, project_key, &current);
8677        return CallgraphRootCandidate::GenerationGc;
8678    }
8679    if memo_referenced {
8680        CallgraphRootCandidate::SkippedMemo
8681    } else {
8682        CallgraphRootCandidate::SkippedDerived
8683    }
8684}
8685
8686fn callgraph_root_file_stats(
8687    cache_dir: &Path,
8688    boundary: &crate::walk_boundary::DeviceBoundary,
8689    deadline: Instant,
8690) -> CallgraphRootWalk {
8691    let metadata = match std::fs::metadata(cache_dir) {
8692        Ok(metadata) => metadata,
8693        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
8694            return CallgraphRootWalk::Complete(CallgraphRootFileStats::default());
8695        }
8696        Err(_) => return CallgraphRootWalk::Failed,
8697    };
8698    let mut stats = CallgraphRootFileStats {
8699        newest: metadata.modified().ok(),
8700        bytes: 0,
8701    };
8702    match callgraph_root_file_stats_inner(cache_dir, boundary, deadline, &mut stats) {
8703        Ok(()) => CallgraphRootWalk::Complete(stats),
8704        Err(CallgraphRootWalkError::BudgetExceeded) => CallgraphRootWalk::BudgetExceeded,
8705        Err(CallgraphRootWalkError::Failed) => CallgraphRootWalk::Failed,
8706    }
8707}
8708
8709enum CallgraphRootWalkError {
8710    BudgetExceeded,
8711    Failed,
8712}
8713
8714fn callgraph_root_file_stats_inner(
8715    directory: &Path,
8716    boundary: &crate::walk_boundary::DeviceBoundary,
8717    deadline: Instant,
8718    stats: &mut CallgraphRootFileStats,
8719) -> std::result::Result<(), CallgraphRootWalkError> {
8720    if Instant::now() >= deadline {
8721        return Err(CallgraphRootWalkError::BudgetExceeded);
8722    }
8723    let entries = std::fs::read_dir(directory).map_err(|_| CallgraphRootWalkError::Failed)?;
8724    for entry in entries {
8725        if Instant::now() >= deadline {
8726            return Err(CallgraphRootWalkError::BudgetExceeded);
8727        }
8728        let entry = entry.map_err(|_| CallgraphRootWalkError::Failed)?;
8729        let file_type = entry
8730            .file_type()
8731            .map_err(|_| CallgraphRootWalkError::Failed)?;
8732        if file_type.is_symlink() {
8733            return Err(CallgraphRootWalkError::Failed);
8734        }
8735        let path = entry.path();
8736        if file_type.is_dir() {
8737            if !boundary
8738                .should_descend(&path)
8739                .map_err(|_| CallgraphRootWalkError::Failed)?
8740            {
8741                return Err(CallgraphRootWalkError::Failed);
8742            }
8743            let metadata = entry
8744                .metadata()
8745                .map_err(|_| CallgraphRootWalkError::Failed)?;
8746            merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8747            callgraph_root_file_stats_inner(&path, boundary, deadline, stats)?;
8748            continue;
8749        }
8750        if !file_type.is_file() {
8751            return Err(CallgraphRootWalkError::Failed);
8752        }
8753        let metadata = entry
8754            .metadata()
8755            .map_err(|_| CallgraphRootWalkError::Failed)?;
8756        stats.bytes = stats.bytes.saturating_add(metadata.len());
8757        merge_newest_callgraph_root_mtime(stats, metadata.modified().ok());
8758    }
8759    Ok(())
8760}
8761
8762fn merge_newest_callgraph_root_mtime(
8763    stats: &mut CallgraphRootFileStats,
8764    modified: Option<SystemTime>,
8765) {
8766    if let Some(modified) = modified {
8767        if stats.newest.is_none_or(|newest| modified > newest) {
8768            stats.newest = Some(modified);
8769        }
8770    }
8771}
8772
8773fn artifact_key_looks_valid(key: &str) -> bool {
8774    key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
8775}
8776
8777#[cfg(test)]
8778fn reset_callgraph_root_sweep_cursor_for_test() {
8779    if let Some(cursors) = CALLGRAPH_ROOT_SWEEP_CURSORS.get() {
8780        cursors.lock().unwrap().clear();
8781    }
8782}
8783
8784/// Minimum age before a cold-build temporary is treated as orphaned and deleted.
8785///
8786/// A cold build writes `<key>.g...sqlite.tmp.<pid>.<ts>` and renames it into
8787/// place on success; a build that dies (process kill, crash, host restart) leaves
8788/// the temporary behind. The largest observed cold build finishes well under a
8789/// day, so a temporary that has sat for 24 hours belongs to a dead build that will
8790/// never rename. A live build's temporary is minutes old at most.
8791///
8792/// The predicate is deliberately AGE-based, not pid-liveness. Pid reuse makes a
8793/// liveness check read false-positive on exactly the oldest files — the ones most
8794/// worth deleting: in production an orphan's embedded pid had been recycled to an
8795/// unrelated live process, so "is the pid alive?" answered yes for garbage. Age
8796/// cannot lie that way, so it is the honest orphan predicate.
8797const ORPHANED_BUILD_TEMP_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
8798
8799/// Best-effort store-wide sweep of orphaned cold-build temporaries. Runs at the
8800/// same cadence as [`gc_old_generations`] (after a generation is published) but,
8801/// unlike it, is not scoped to the building root: it covers every directory in the
8802/// callgraph store so orphans left by a root that STOPPED building are reclaimed.
8803///
8804/// That last case is the production hole this fixes. The per-root cleanup in
8805/// [`gc_old_generations`] only fires when a root actually builds, so when activity
8806/// moves away (e.g. the root-keyed migration moved builds to a new store) the old
8807/// store's orphans become permanent — gigabytes accumulated in a legacy store
8808/// whose roots no longer built there, while the active store stayed clean. A
8809/// sibling root that still builds triggers this pass and cleans both layouts.
8810fn sweep_orphaned_build_temps_store_wide(callgraph_dir: &Path) {
8811    sweep_orphaned_build_temps(callgraph_dir);
8812    let Some(storage_root) = root_storage_dir(callgraph_dir) else {
8813        return;
8814    };
8815    let domain = crate::root_cache::RootCacheDomain::Callgraph.as_str();
8816    // A vanished mounted child can make ReadDir::drop panic after closedir
8817    // returns ENXIO, aborting the daemon. Keep the store-wide background sweep
8818    // on the storage root's filesystem before opening child directories.
8819    let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(&storage_root) else {
8820        crate::slog_warn!(
8821            "cannot establish filesystem boundary for callgraph sweep {}",
8822            storage_root.display()
8823        );
8824        return;
8825    };
8826    let mut skipped_foreign_mounts = 0usize;
8827
8828    // Root-keyed layout: every `<storage>/callgraph/<key>` directory.
8829    let root_keyed_dir = storage_root.join(domain);
8830    if root_keyed_dir.is_dir() {
8831        if boundary.should_descend(&root_keyed_dir).unwrap_or(false) {
8832            if let Ok(entries) = std::fs::read_dir(&root_keyed_dir) {
8833                for entry in entries.flatten() {
8834                    let path = entry.path();
8835                    if path.is_dir() {
8836                        if boundary.should_descend(&path).unwrap_or(false) {
8837                            sweep_orphaned_build_temps(&path);
8838                        } else {
8839                            skipped_foreign_mounts += 1;
8840                        }
8841                    }
8842                }
8843            }
8844        } else {
8845            skipped_foreign_mounts += 1;
8846        }
8847    }
8848
8849    // Legacy per-harness layout: every `<storage>/<harness>/callgraph` directory.
8850    if let Ok(entries) = std::fs::read_dir(&storage_root) {
8851        for entry in entries.flatten() {
8852            let harness_dir = entry.path();
8853            if !harness_dir.is_dir() {
8854                continue;
8855            }
8856            if !boundary.should_descend(&harness_dir).unwrap_or(false) {
8857                skipped_foreign_mounts += 1;
8858                continue;
8859            }
8860            let legacy_dir = harness_dir.join(domain);
8861            if legacy_dir.is_dir() {
8862                if boundary.should_descend(&legacy_dir).unwrap_or(false) {
8863                    sweep_orphaned_build_temps(&legacy_dir);
8864                } else {
8865                    skipped_foreign_mounts += 1;
8866                }
8867            }
8868        }
8869    }
8870    if skipped_foreign_mounts > 0 {
8871        crate::slog_warn!(
8872            "callgraph sweep skipped {} foreign filesystem mount(s) below {}",
8873            skipped_foreign_mounts,
8874            storage_root.display()
8875        );
8876    }
8877}
8878
8879/// Sweep one callgraph directory, removing build temporaries older than
8880/// [`ORPHANED_BUILD_TEMP_MIN_AGE`].
8881fn sweep_orphaned_build_temps(callgraph_dir: &Path) {
8882    sweep_orphaned_build_temps_older_than(callgraph_dir, ORPHANED_BUILD_TEMP_MIN_AGE);
8883}
8884
8885/// Inner sweep with an explicit age threshold so tests can exercise the predicate.
8886/// See [`ORPHANED_BUILD_TEMP_MIN_AGE`] for why the predicate is age, not pid.
8887fn sweep_orphaned_build_temps_older_than(callgraph_dir: &Path, min_age: Duration) {
8888    let now = SystemTime::now();
8889    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
8890        return;
8891    };
8892    let mut removed_any = false;
8893    for entry in entries.flatten() {
8894        let name = entry.file_name().to_string_lossy().to_string();
8895        // Build-temporary shape: `<key>.g...sqlite.tmp.<pid>.<ts>`. The
8896        // `-journal`/`-wal`/`-shm` sidecars append their suffix AFTER the temp
8897        // name, so they still contain `.sqlite.tmp.` and match here too. Anything
8898        // without that substring — a completed `.sqlite` generation, a pointer, a
8899        // read-marker dir — is left alone: those belong to generation GC.
8900        if !name.contains(".sqlite.tmp.") {
8901            continue;
8902        }
8903        let mtime = entry
8904            .metadata()
8905            .and_then(|meta| meta.modified())
8906            .unwrap_or(now);
8907        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < min_age {
8908            continue;
8909        }
8910        // Deletion races a concurrent build finishing: that build renames the temp
8911        // into place, so the file is gone by the time we unlink. The 24h age makes
8912        // this overlap practically impossible, but treat a missing file as success
8913        // (the rename won) rather than an error, and never touch a path that does
8914        // not match the temporary shape above.
8915        match std::fs::remove_file(entry.path()) {
8916            Ok(()) => removed_any = true,
8917            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
8918            Err(_) => {}
8919        }
8920    }
8921    if removed_any {
8922        crate::fs_lock::sync_parent(callgraph_dir);
8923    }
8924}
8925
8926/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
8927/// the global all-cores rayon pool. The store cold-build is the heaviest
8928/// background pass (parse-dominated) and runs on a separate thread off the
8929/// single-threaded request loop; left unbounded it monopolizes every core and
8930/// starves the bridge so interactive tools time out (the same starvation the
8931/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
8932/// match the main thread, since the extract walks tree-sitter ASTs.
8933fn build_pool_size() -> usize {
8934    std::thread::available_parallelism()
8935        .map(|parallelism| parallelism.get())
8936        .unwrap_or(1)
8937        .div_ceil(2)
8938        .clamp(1, 8)
8939}
8940
8941fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
8942    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
8943        Ok(extract) => Ok(extract),
8944        Err(error) => {
8945            let abs_path =
8946                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
8947            let rel_path = relative_path(project_root, &abs_path);
8948            let freshness = cache_freshness::collect(&abs_path).ok();
8949            log::debug!(
8950                "callgraph store: skipping {} during cold build: {}",
8951                abs_path.display(),
8952                error
8953            );
8954            Err(ExtractFailure {
8955                rel_path,
8956                freshness,
8957            })
8958        }
8959    };
8960
8961    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
8962        files.par_iter().map(extract_one).collect()
8963    };
8964
8965    // Run inside a dedicated bounded pool when one builds; fall back to the
8966    // global pool only if the bounded pool can't be constructed.
8967    let results = match rayon::ThreadPoolBuilder::new()
8968        .num_threads(build_pool_size())
8969        .thread_name(|index| format!("aft-callgraph-build-{index}"))
8970        .stack_size(8 * 1024 * 1024)
8971        .build()
8972    {
8973        Ok(pool) => pool.install(run),
8974        Err(error) => {
8975            log::warn!(
8976                "callgraph store: bounded build pool unavailable ({error}); using global pool"
8977            );
8978            run()
8979        }
8980    };
8981
8982    let mut extracts = Vec::new();
8983    let mut failures = Vec::new();
8984    for result in results {
8985        match result {
8986            Ok(extract) => extracts.push(extract),
8987            Err(failure) => failures.push(failure),
8988        }
8989    }
8990    BuildExtractsResult { extracts, failures }
8991}
8992
8993fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
8994    let metadata = std::fs::metadata(path)?;
8995    let size = metadata.len();
8996    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
8997        cache_freshness::zero_hash()
8998    } else if source.len() as u64 == size {
8999        cache_freshness::hash_bytes(source.as_bytes())
9000    } else {
9001        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
9002    };
9003    Ok(FileFreshness {
9004        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
9005        size,
9006        content_hash,
9007    })
9008}
9009
9010fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
9011    let abs_path = normalize_file_path(project_root, path)?;
9012    let rel_path = relative_path(project_root, &abs_path);
9013    let source = std::fs::read_to_string(&abs_path)?;
9014    let freshness = collect_source_freshness(&abs_path, &source)?;
9015    let mut data = callgraph::build_file_data_from_source(&abs_path, &source)?;
9016    let lang = data.lang;
9017    if lang == LangId::Rust {
9018        extend_rust_imports_with_nested_uses(&source, &mut data);
9019    }
9020    let mut nodes = build_node_records(&rel_path, &source, &data)?;
9021    let node_by_scoped: HashMap<String, String> = nodes
9022        .iter()
9023        .map(|node| (node.scoped_name.clone(), node.id.clone()))
9024        .collect();
9025    let import_dependencies = import_dependencies(
9026        project_root,
9027        &abs_path,
9028        &data.import_block.imports,
9029        &FactPaths {
9030            root: project_root,
9031            facts: &DiskFacts::new(project_root),
9032        },
9033    );
9034    let line_index = LineIndex::new(&source);
9035    let reexports = collect_reexport_refs(
9036        project_root,
9037        &abs_path,
9038        &rel_path,
9039        &source,
9040        &FactPaths {
9041            root: project_root,
9042            facts: &DiskFacts::new(project_root),
9043        },
9044    );
9045    let rust_reexports = if lang == LangId::Rust {
9046        collect_rust_pub_use_reexport_refs(
9047            project_root,
9048            &abs_path,
9049            &rel_path,
9050            &data.import_block.imports,
9051            &line_index,
9052            &FactPaths {
9053                root: project_root,
9054                facts: &DiskFacts::new(project_root),
9055            },
9056        )
9057    } else {
9058        ReexportRefs {
9059            raw_refs: Vec::new(),
9060            surface_parts: Vec::new(),
9061        }
9062    };
9063    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
9064    let mut raw_refs = Vec::new();
9065    raw_refs.extend(build_call_refs(
9066        &rel_path,
9067        &data,
9068        &node_by_scoped,
9069        &import_dependencies,
9070    ));
9071    raw_refs.extend(build_value_ref_refs(
9072        &rel_path,
9073        &data,
9074        &node_by_scoped,
9075        &import_dependencies,
9076    ));
9077    raw_refs.extend(build_import_refs(
9078        project_root,
9079        &abs_path,
9080        &rel_path,
9081        &data.import_block.imports,
9082        &line_index,
9083        &FactPaths {
9084            root: project_root,
9085            facts: &DiskFacts::new(project_root),
9086        },
9087    ));
9088    if lang == LangId::Rust {
9089        raw_refs.extend(build_rust_module_refs(
9090            project_root,
9091            &abs_path,
9092            &rel_path,
9093            &source,
9094            &FactPaths {
9095                root: project_root,
9096                facts: &DiskFacts::new(project_root),
9097            },
9098        ));
9099    }
9100    let mut surface_parts = reexports.surface_parts;
9101    surface_parts.extend(rust_reexports.surface_parts);
9102    surface_parts.extend(source_less_exports.surface_parts);
9103    raw_refs.extend(reexports.raw_refs);
9104    raw_refs.extend(rust_reexports.raw_refs);
9105    raw_refs.extend(source_less_exports.raw_refs);
9106    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
9107    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
9108
9109    Ok(FileExtract {
9110        rel_path,
9111        freshness,
9112        lang,
9113        data,
9114        nodes,
9115        raw_refs,
9116        dispatch_hints,
9117        surface_fingerprint,
9118    })
9119}
9120
9121fn build_node_records(
9122    rel_path: &str,
9123    source: &str,
9124    data: &FileCallData,
9125) -> Result<Vec<NodeRecord>> {
9126    let mut records = Vec::new();
9127    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
9128    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
9129    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
9130
9131    for (scoped_name, meta) in metadata {
9132        let name = unqualified_name(scoped_name).to_string();
9133        let range = selection_range(source, scoped_name, &name, &meta.range);
9134        let range_key = (
9135            range.start_line,
9136            range.start_col,
9137            range.end_line,
9138            range.end_col,
9139        );
9140        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
9141        let range_ordinal = *ordinal;
9142        *ordinal += 1;
9143        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
9144        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
9145        let is_default_export = data
9146            .default_export_symbol
9147            .as_deref()
9148            .map(|default| default == scoped_name || default == name)
9149            .unwrap_or(false);
9150        records.push(NodeRecord {
9151            id,
9152            file_path: rel_path.to_string(),
9153            name: name.clone(),
9154            scoped_name: scoped_name.clone(),
9155            kind: symbol_kind_label(&meta.kind).to_string(),
9156            range,
9157            range_ordinal,
9158            signature: meta.signature.clone(),
9159            exported,
9160            is_default_export,
9161            is_type_like: is_type_like(&meta.kind),
9162            is_callgraph_entry_point: meta.entry_point_attribute.is_some()
9163                || callgraph::is_entry_point(scoped_name, &meta.kind, exported, data.lang),
9164        });
9165    }
9166
9167    Ok(records)
9168}
9169
9170fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
9171    if scoped_name == TOP_LEVEL_SYMBOL {
9172        return Range {
9173            start_line: 0,
9174            start_col: 0,
9175            end_line: 0,
9176            end_col: 0,
9177        };
9178    }
9179    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
9180        return fallback.clone();
9181    };
9182    let start_col = fallback.start_col as usize;
9183    let search_start = start_col.min(line.len());
9184    if let Some(offset) = line[search_start..].find(name) {
9185        let col = search_start + offset;
9186        return Range {
9187            start_line: fallback.start_line,
9188            start_col: col as u32,
9189            end_line: fallback.start_line,
9190            end_col: (col + name.len()) as u32,
9191        };
9192    }
9193    if let Some(offset) = line.find(name) {
9194        return Range {
9195            start_line: fallback.start_line,
9196            start_col: offset as u32,
9197            end_line: fallback.start_line,
9198            end_col: (offset + name.len()) as u32,
9199        };
9200    }
9201    Range {
9202        start_line: fallback.start_line,
9203        start_col: fallback.start_col,
9204        end_line: fallback.start_line,
9205        end_col: fallback.start_col.saturating_add(name.len() as u32),
9206    }
9207}
9208
9209fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
9210    if scoped_name == TOP_LEVEL_SYMBOL {
9211        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
9212    }
9213    let input = format!(
9214        "{rel_path}:{}:{}:{}:{}:{ordinal}",
9215        range.start_line, range.start_col, range.end_line, range.end_col
9216    );
9217    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
9218}
9219
9220fn build_call_refs(
9221    rel_path: &str,
9222    data: &FileCallData,
9223    node_by_scoped: &HashMap<String, String>,
9224    import_dependencies: &BTreeSet<String>,
9225) -> Vec<RawRef> {
9226    build_callable_refs(
9227        rel_path,
9228        &data.calls_by_symbol,
9229        node_by_scoped,
9230        import_dependencies,
9231        "call",
9232    )
9233}
9234
9235fn build_value_ref_refs(
9236    rel_path: &str,
9237    data: &FileCallData,
9238    node_by_scoped: &HashMap<String, String>,
9239    import_dependencies: &BTreeSet<String>,
9240) -> Vec<RawRef> {
9241    build_callable_refs(
9242        rel_path,
9243        &data.value_refs_by_symbol,
9244        node_by_scoped,
9245        import_dependencies,
9246        "value_ref",
9247    )
9248}
9249
9250fn build_callable_refs(
9251    rel_path: &str,
9252    sites_by_symbol: &HashMap<String, Vec<callgraph::CallSite>>,
9253    node_by_scoped: &HashMap<String, String>,
9254    import_dependencies: &BTreeSet<String>,
9255    kind: &str,
9256) -> Vec<RawRef> {
9257    let mut refs = Vec::new();
9258    let mut ordinal = 0usize;
9259    let mut symbols: Vec<_> = sites_by_symbol.iter().collect();
9260    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
9261    for (caller_symbol, call_sites) in symbols {
9262        let caller_node = node_by_scoped.get(caller_symbol).cloned();
9263        for call_site in call_sites {
9264            ordinal += 1;
9265            let ref_id = ref_id(&[
9266                rel_path,
9267                kind,
9268                caller_symbol,
9269                &call_site.line.to_string(),
9270                &call_site.byte_start.to_string(),
9271                &call_site.byte_end.to_string(),
9272                &call_site.full_callee,
9273                &ordinal.to_string(),
9274            ]);
9275            refs.push(RawRef {
9276                ref_id,
9277                caller_node: caller_node.clone(),
9278                caller_symbol: Some(caller_symbol.clone()),
9279                caller_file: rel_path.to_string(),
9280                kind: kind.to_string(),
9281                short_name: Some(call_site.callee_name.clone()),
9282                full_ref: Some(call_site.full_callee.clone()),
9283                module_path: None,
9284                import_kind: None,
9285                local_name: Some(call_site.callee_name.clone()),
9286                requested_name: Some(call_site.callee_name.clone()),
9287                namespace_alias: namespace_alias(&call_site.full_callee),
9288                wildcard: false,
9289                line: call_site.line,
9290                byte_start: call_site.byte_start,
9291                byte_end: call_site.byte_end,
9292                dependencies: import_dependencies.clone(),
9293            });
9294        }
9295    }
9296    refs
9297}
9298
9299fn build_import_refs(
9300    project_root: &Path,
9301    abs_path: &Path,
9302    rel_path: &str,
9303    imports: &[ImportStatement],
9304    line_index: &LineIndex,
9305    facts: &FactPaths<'_>,
9306) -> Vec<RawRef> {
9307    let mut refs = Vec::new();
9308    for (index, import) in imports.iter().enumerate() {
9309        let import_kind = import_kind_label(import.kind).to_string();
9310        let local_name = import_local_names(import).join(",");
9311        let requested_name = import_requested_names(import).join(",");
9312        let ref_id = ref_id(&[
9313            rel_path,
9314            "import",
9315            &import.byte_range.start.to_string(),
9316            &import.byte_range.end.to_string(),
9317            &import.module_path,
9318            &index.to_string(),
9319        ]);
9320        refs.push(RawRef {
9321            ref_id,
9322            caller_node: None,
9323            caller_symbol: None,
9324            caller_file: rel_path.to_string(),
9325            kind: "import".to_string(),
9326            short_name: None,
9327            full_ref: Some(import.raw_text.clone()),
9328            module_path: Some(import.module_path.clone()),
9329            import_kind: Some(import_kind),
9330            local_name: empty_to_none(local_name),
9331            requested_name: empty_to_none(requested_name),
9332            namespace_alias: import.namespace_import.clone(),
9333            wildcard: import_is_wildcard(import),
9334            line: line_index.byte_to_line(import.byte_range.start),
9335            byte_start: import.byte_range.start,
9336            byte_end: import.byte_range.end,
9337            dependencies: module_dependencies(project_root, abs_path, &import.module_path, facts),
9338        });
9339    }
9340    refs
9341}
9342
9343fn build_rust_module_refs(
9344    project_root: &Path,
9345    abs_path: &Path,
9346    rel_path: &str,
9347    source: &str,
9348    facts: &FactPaths<'_>,
9349) -> Vec<RawRef> {
9350    let grammar = grammar_for(LangId::Rust);
9351    let mut parser = Parser::new();
9352    if parser.set_language(&grammar).is_err() {
9353        return Vec::new();
9354    }
9355    let Some(tree) = parser.parse(source, None) else {
9356        return Vec::new();
9357    };
9358
9359    let mut refs = Vec::new();
9360    let mut stack = vec![tree.root_node()];
9361    while let Some(node) = stack.pop() {
9362        if node.kind() == "mod_item"
9363            && node
9364                .named_children(&mut node.walk())
9365                .all(|child| child.kind() != "declaration_list")
9366        {
9367            if let Some(name_node) = node.child_by_field_name("name") {
9368                let module_name = node_text(name_node, source).to_string();
9369                let target = rust_external_module_target(
9370                    abs_path,
9371                    rust_module_path_override(source, node),
9372                    &module_name,
9373                    facts,
9374                );
9375                let mut dependencies = BTreeSet::new();
9376                if let Some(target) = target {
9377                    dependencies.insert(relative_path(project_root, &canonicalize_path(&target)));
9378                }
9379                refs.push(RawRef {
9380                    ref_id: ref_id(&[
9381                        rel_path,
9382                        "module",
9383                        &module_name,
9384                        &node.start_byte().to_string(),
9385                    ]),
9386                    caller_node: None,
9387                    caller_symbol: None,
9388                    caller_file: rel_path.to_string(),
9389                    kind: "module".to_string(),
9390                    short_name: Some(module_name.clone()),
9391                    full_ref: Some(module_name.clone()),
9392                    module_path: Some(module_name.clone()),
9393                    import_kind: Some("module".to_string()),
9394                    local_name: Some(module_name.clone()),
9395                    requested_name: Some(module_name),
9396                    namespace_alias: None,
9397                    wildcard: false,
9398                    line: node.start_position().row as u32 + 1,
9399                    byte_start: node.start_byte(),
9400                    byte_end: node.end_byte(),
9401                    dependencies,
9402                });
9403            }
9404        }
9405
9406        let mut cursor = node.walk();
9407        if cursor.goto_first_child() {
9408            loop {
9409                stack.push(cursor.node());
9410                if !cursor.goto_next_sibling() {
9411                    break;
9412                }
9413            }
9414        }
9415    }
9416    refs.sort_by_key(|raw| (raw.byte_start, raw.byte_end));
9417    refs
9418}
9419
9420fn rust_declared_module_target(
9421    project_root: &Path,
9422    caller_file: &str,
9423    module_name: &str,
9424    memo: &callgraph::ModuleResolutionMemo,
9425    facts: &FactPaths<'_>,
9426) -> Option<String> {
9427    memo.rust_declared_module_target(caller_file, module_name, || {
9428        rust_declared_module_targets(project_root, caller_file, facts)
9429    })
9430}
9431
9432fn rust_declared_module_targets(
9433    project_root: &Path,
9434    caller_file: &str,
9435    facts: &FactPaths<'_>,
9436) -> HashMap<String, Option<String>> {
9437    let declaring_file = project_root.join(caller_file);
9438    let Ok(source) = std::fs::read_to_string(&declaring_file) else {
9439        return HashMap::new();
9440    };
9441    let Ok(tree) = parse_source_with_cached_parser(&declaring_file, &source, LangId::Rust) else {
9442        return HashMap::new();
9443    };
9444    let mut targets = HashMap::new();
9445    let mut stack = vec![tree.root_node()];
9446    while let Some(node) = stack.pop() {
9447        if node.kind() == "mod_item"
9448            && node
9449                .named_children(&mut node.walk())
9450                .all(|child| child.kind() != "declaration_list")
9451        {
9452            if let Some(name) = node.child_by_field_name("name") {
9453                let module_name = node_text(name, &source);
9454                let target = rust_external_module_target(
9455                    &declaring_file,
9456                    rust_module_path_override(&source, node),
9457                    module_name,
9458                    facts,
9459                )
9460                .map(|target| {
9461                    relative_path(
9462                        project_root,
9463                        &facts.canonical(&target).unwrap_or(target.clone()),
9464                    )
9465                });
9466                targets.entry(module_name.to_string()).or_insert(target);
9467            }
9468        }
9469        let mut cursor = node.walk();
9470        if cursor.goto_first_child() {
9471            loop {
9472                stack.push(cursor.node());
9473                if !cursor.goto_next_sibling() {
9474                    break;
9475                }
9476            }
9477        }
9478    }
9479    targets
9480}
9481
9482fn rust_external_module_target(
9483    declaring_file: &Path,
9484    path_override: Option<&str>,
9485    module_name: &str,
9486    facts: &FactPaths<'_>,
9487) -> Option<PathBuf> {
9488    let parent = declaring_file.parent()?;
9489    if let Some(path) = path_override {
9490        let candidate = parent.join(path);
9491        return facts.is_file(&candidate).then_some(candidate);
9492    }
9493
9494    let stem = declaring_file.file_stem().and_then(|stem| stem.to_str())?;
9495    let declaring_file = facts
9496        .canonical(declaring_file)
9497        .unwrap_or_else(|| declaring_file.to_path_buf());
9498    let is_crate_root = callgraph::rust_crate_root_file_for_caller(
9499        facts.root,
9500        &declaring_file,
9501        facts,
9502        &callgraph::RustCrateRootMemo::default(),
9503    )
9504    .as_ref()
9505        == Some(&declaring_file);
9506    let module_dir = if matches!(stem, "lib" | "main" | "mod") || is_crate_root {
9507        parent.to_path_buf()
9508    } else {
9509        parent.join(stem)
9510    };
9511    [
9512        module_dir.join(format!("{module_name}.rs")),
9513        module_dir.join(module_name).join("mod.rs"),
9514    ]
9515    .into_iter()
9516    .find(|candidate| facts.is_file(candidate))
9517}
9518
9519fn rust_module_path_override<'a>(source: &'a str, module: Node<'_>) -> Option<&'a str> {
9520    let mut previous = module.prev_sibling();
9521    while let Some(attribute) = previous {
9522        if attribute.kind() != "attribute_item" {
9523            break;
9524        }
9525        if let Some(path) = rust_path_attribute(source.get(attribute.byte_range())?) {
9526            return Some(path);
9527        }
9528        previous = attribute.prev_sibling();
9529    }
9530    None
9531}
9532
9533fn rust_path_attribute(attribute: &str) -> Option<&str> {
9534    let body = attribute.trim().strip_prefix("#[")?.strip_suffix(']')?;
9535    let (name, value) = body.split_once('=')?;
9536    (name.trim() == "path")
9537        .then(|| value.trim().trim_matches('"'))
9538        .filter(|path| !path.is_empty())
9539}
9540
9541fn extend_rust_imports_with_nested_uses(source: &str, data: &mut FileCallData) {
9542    let grammar = grammar_for(LangId::Rust);
9543    let mut parser = Parser::new();
9544    if parser.set_language(&grammar).is_err() {
9545        return;
9546    }
9547    let Some(tree) = parser.parse(source, None) else {
9548        return;
9549    };
9550
9551    let mut seen = data
9552        .import_block
9553        .imports
9554        .iter()
9555        .map(|import| (import.byte_range.start, import.byte_range.end))
9556        .collect::<HashSet<_>>();
9557    let mut nested_imports = Vec::new();
9558    collect_rust_use_imports(source, tree.root_node(), &mut seen, &mut nested_imports);
9559    if nested_imports.is_empty() {
9560        return;
9561    }
9562
9563    data.import_block.imports.extend(nested_imports);
9564    data.import_block
9565        .imports
9566        .sort_by_key(|import| import.byte_range.start);
9567    data.import_block.byte_range = import_byte_range_from_imports(&data.import_block.imports);
9568}
9569
9570fn collect_rust_use_imports(
9571    source: &str,
9572    node: Node<'_>,
9573    seen: &mut HashSet<(usize, usize)>,
9574    imports: &mut Vec<ImportStatement>,
9575) {
9576    if node.kind() == "use_declaration" {
9577        let range = node.byte_range();
9578        if seen.insert((range.start, range.end)) {
9579            if let Some(import) = rust_import_from_use_node(source, node) {
9580                imports.push(import);
9581            }
9582        }
9583    }
9584
9585    let mut cursor = node.walk();
9586    if !cursor.goto_first_child() {
9587        return;
9588    }
9589    loop {
9590        collect_rust_use_imports(source, cursor.node(), seen, imports);
9591        if !cursor.goto_next_sibling() {
9592            break;
9593        }
9594    }
9595}
9596
9597fn rust_import_from_use_node(source: &str, node: Node<'_>) -> Option<ImportStatement> {
9598    let raw_text = source[node.byte_range()].to_string();
9599    let body = rust_use_body(&raw_text)?.to_string();
9600    let visibility = rust_use_visibility(&raw_text);
9601    let names = rust_use_list_names(&body);
9602    let group = classify_rust_import_group(&body);
9603    let byte_range = node.byte_range();
9604
9605    Some(ImportStatement {
9606        module_path: body,
9607        names: names.clone(),
9608        default_import: visibility.clone(),
9609        namespace_import: None,
9610        kind: ImportKind::Value,
9611        group,
9612        byte_range,
9613        raw_text,
9614        form: ImportForm::RustUse {
9615            visibility,
9616            named: names,
9617        },
9618    })
9619}
9620
9621fn import_byte_range_from_imports(imports: &[ImportStatement]) -> Option<std::ops::Range<usize>> {
9622    let start = imports.iter().map(|import| import.byte_range.start).min()?;
9623    let end = imports.iter().map(|import| import.byte_range.end).max()?;
9624    Some(start..end)
9625}
9626
9627fn rust_use_visibility(raw_text: &str) -> Option<String> {
9628    let use_pos = raw_text.find("use ")?;
9629    let prefix = raw_text[..use_pos].trim();
9630    if prefix.is_empty() {
9631        None
9632    } else {
9633        Some(prefix.to_string())
9634    }
9635}
9636
9637fn rust_use_body(raw_text: &str) -> Option<&str> {
9638    let use_pos = raw_text.find("use ")?;
9639    Some(raw_text[use_pos + 4..].trim().trim_end_matches(';').trim())
9640}
9641
9642fn rust_use_list_names(body: &str) -> Vec<String> {
9643    let Some(open) = body.find("::{") else {
9644        return Vec::new();
9645    };
9646    let Some(close) = body[open + 3..].find('}').map(|offset| open + 3 + offset) else {
9647        return Vec::new();
9648    };
9649    body[open + 3..close]
9650        .split(',')
9651        .filter_map(|spec| {
9652            let spec = spec.trim();
9653            if spec.is_empty() {
9654                None
9655            } else {
9656                Some(spec.to_string())
9657            }
9658        })
9659        .collect()
9660}
9661
9662fn classify_rust_import_group(body: &str) -> ImportGroup {
9663    let first = body
9664        .split("::")
9665        .next()
9666        .unwrap_or(body)
9667        .split_whitespace()
9668        .next()
9669        .unwrap_or(body);
9670    match first.trim() {
9671        "std" | "core" | "alloc" => ImportGroup::Stdlib,
9672        "crate" | "self" | "super" => ImportGroup::Internal,
9673        _ => ImportGroup::External,
9674    }
9675}
9676
9677#[derive(Debug, Clone)]
9678struct ReexportRefs {
9679    raw_refs: Vec<RawRef>,
9680    surface_parts: Vec<String>,
9681}
9682
9683fn collect_reexport_refs(
9684    project_root: &Path,
9685    abs_path: &Path,
9686    rel_path: &str,
9687    source: &str,
9688    facts: &FactPaths<'_>,
9689) -> ReexportRefs {
9690    let mut raw_refs = Vec::new();
9691    let mut surface_parts = Vec::new();
9692    let mut search_start = 0usize;
9693    let mut ordinal = 0usize;
9694    while let Some(export_offset) = source[search_start..].find("export") {
9695        let start = search_start + export_offset;
9696        let Some(statement_end_offset) = source[start..].find(';') else {
9697            break;
9698        };
9699        let end = start + statement_end_offset + 1;
9700        let statement = &source[start..end];
9701        search_start = end;
9702        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
9703            continue;
9704        }
9705        let Some(module_path) = quoted_module_path(statement) else {
9706            continue;
9707        };
9708        ordinal += 1;
9709        let wildcard = statement.contains('*');
9710        let line = source[..start]
9711            .bytes()
9712            .filter(|byte| *byte == b'\n')
9713            .count() as u32
9714            + 1;
9715        let ref_id = ref_id(&[
9716            rel_path,
9717            "reexport",
9718            &start.to_string(),
9719            &end.to_string(),
9720            &module_path,
9721            &ordinal.to_string(),
9722        ]);
9723        surface_parts.push(format!("reexport\t{statement}"));
9724        raw_refs.push(RawRef {
9725            ref_id,
9726            caller_node: None,
9727            caller_symbol: None,
9728            caller_file: rel_path.to_string(),
9729            kind: "reexport".to_string(),
9730            short_name: None,
9731            full_ref: Some(statement.to_string()),
9732            module_path: Some(module_path.clone()),
9733            import_kind: Some("reexport".to_string()),
9734            local_name: None,
9735            requested_name: None,
9736            namespace_alias: None,
9737            wildcard,
9738            line,
9739            byte_start: start,
9740            byte_end: end,
9741            dependencies: module_dependencies(project_root, abs_path, &module_path, facts),
9742        });
9743    }
9744    ReexportRefs {
9745        raw_refs,
9746        surface_parts,
9747    }
9748}
9749
9750fn collect_rust_pub_use_reexport_refs(
9751    project_root: &Path,
9752    abs_path: &Path,
9753    rel_path: &str,
9754    imports: &[ImportStatement],
9755    line_index: &LineIndex,
9756    facts: &FactPaths<'_>,
9757) -> ReexportRefs {
9758    let mut raw_refs = Vec::new();
9759    let mut surface_parts = Vec::new();
9760    let mut ordinal = 0usize;
9761
9762    for import in imports {
9763        let Some(visibility) = &import.default_import else {
9764            continue;
9765        };
9766        if !visibility.starts_with("pub") {
9767            continue;
9768        }
9769        let Some((module_path, named, wildcard)) = rust_pub_use_reexport_parts(import) else {
9770            continue;
9771        };
9772        ordinal += 1;
9773        let ref_id = ref_id(&[
9774            rel_path,
9775            "rust_reexport",
9776            &import.byte_range.start.to_string(),
9777            &import.byte_range.end.to_string(),
9778            &module_path,
9779            &ordinal.to_string(),
9780        ]);
9781        surface_parts.push(format!("reexport\t{}", import.raw_text));
9782        raw_refs.push(RawRef {
9783            ref_id,
9784            caller_node: None,
9785            caller_symbol: None,
9786            caller_file: rel_path.to_string(),
9787            kind: "reexport".to_string(),
9788            short_name: None,
9789            full_ref: Some(rust_reexport_statement_for_index(&named, &import.raw_text)),
9790            module_path: Some(module_path.clone()),
9791            import_kind: Some("reexport".to_string()),
9792            local_name: None,
9793            requested_name: None,
9794            namespace_alias: None,
9795            wildcard,
9796            line: line_index.byte_to_line(import.byte_range.start),
9797            byte_start: import.byte_range.start,
9798            byte_end: import.byte_range.end,
9799            dependencies: rust_module_dependencies(project_root, abs_path, &module_path, facts),
9800        });
9801    }
9802
9803    ReexportRefs {
9804        raw_refs,
9805        surface_parts,
9806    }
9807}
9808
9809fn rust_pub_use_reexport_parts(
9810    import: &ImportStatement,
9811) -> Option<(String, HashMap<String, String>, bool)> {
9812    let body = rust_use_body(&import.raw_text).unwrap_or(import.module_path.as_str());
9813    let body = body.trim();
9814    if let Some(module_path) = body.strip_suffix("::*") {
9815        return Some((module_path.trim().to_string(), HashMap::new(), true));
9816    }
9817
9818    if let Some(brace_start) = body.find("::{") {
9819        let module_path = body[..brace_start].trim().to_string();
9820        let names = rust_reexport_names_from_specs(&body[brace_start + 3..body.rfind('}')?]);
9821        if names.is_empty() {
9822            return None;
9823        }
9824        return Some((module_path, names, false));
9825    }
9826
9827    let (module_path, spec) = body.rsplit_once("::")?;
9828    let names = rust_reexport_names_from_specs(spec);
9829    if names.is_empty() {
9830        return None;
9831    }
9832    Some((module_path.trim().to_string(), names, false))
9833}
9834
9835fn rust_reexport_names_from_specs(specs: &str) -> HashMap<String, String> {
9836    let mut names = HashMap::new();
9837    for spec in specs.split(',') {
9838        let spec = spec.trim();
9839        if spec.is_empty() || spec == "self" {
9840            continue;
9841        }
9842        if let Some((source, local)) = spec.split_once(" as ") {
9843            let source = source.trim();
9844            let local = local.trim();
9845            if !source.is_empty() && !local.is_empty() && source != "self" {
9846                names.insert(local.to_string(), source.to_string());
9847            }
9848        } else {
9849            names.insert(spec.to_string(), spec.to_string());
9850        }
9851    }
9852    names
9853}
9854
9855fn rust_reexport_statement_for_index(named: &HashMap<String, String>, fallback: &str) -> String {
9856    if named.is_empty() {
9857        return fallback.to_string();
9858    }
9859    let mut specs = named
9860        .iter()
9861        .map(|(local, source)| {
9862            if local == source {
9863                source.clone()
9864            } else {
9865                format!("{source} as {local}")
9866            }
9867        })
9868        .collect::<Vec<_>>();
9869    specs.sort();
9870    format!("pub use {{{}}};", specs.join(", "))
9871}
9872
9873fn quoted_module_path(statement: &str) -> Option<String> {
9874    let quote = match (statement.find('\''), statement.find('"')) {
9875        (Some(single), Some(double)) if single < double => '\'',
9876        (Some(_), Some(_)) => '"',
9877        (Some(_), None) => '\'',
9878        (None, Some(_)) => '"',
9879        (None, None) => return None,
9880    };
9881    let start = statement.find(quote)? + 1;
9882    let end = statement[start..].find(quote)? + start;
9883    Some(statement[start..end].to_string())
9884}
9885
9886#[derive(Debug, Clone)]
9887struct SourceLessExportRefs {
9888    raw_refs: Vec<RawRef>,
9889    surface_parts: Vec<String>,
9890}
9891
9892fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
9893    let mut raw_refs = Vec::new();
9894    let mut surface_parts = Vec::new();
9895    let mut search_start = 0usize;
9896    let mut ordinal = 0usize;
9897    while let Some(export_offset) = source[search_start..].find("export") {
9898        let start = search_start + export_offset;
9899        let Some(statement_end_offset) = source[start..].find(';') else {
9900            break;
9901        };
9902        let end = start + statement_end_offset + 1;
9903        let statement = &source[start..end];
9904        search_start = end;
9905        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
9906            continue;
9907        }
9908        let aliases = parse_reexport_names(statement);
9909        if aliases.is_empty() {
9910            continue;
9911        }
9912        let line = source[..start]
9913            .bytes()
9914            .filter(|byte| *byte == b'\n')
9915            .count() as u32
9916            + 1;
9917        for (exported, source_symbol) in aliases {
9918            ordinal += 1;
9919            let ref_id = ref_id(&[
9920                rel_path,
9921                "export_alias",
9922                &start.to_string(),
9923                &end.to_string(),
9924                &exported,
9925                &source_symbol,
9926                &ordinal.to_string(),
9927            ]);
9928            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
9929            raw_refs.push(RawRef {
9930                ref_id,
9931                caller_node: None,
9932                caller_symbol: None,
9933                caller_file: rel_path.to_string(),
9934                kind: "export_alias".to_string(),
9935                short_name: None,
9936                full_ref: Some(statement.to_string()),
9937                module_path: None,
9938                import_kind: Some("export_alias".to_string()),
9939                local_name: Some(exported),
9940                requested_name: Some(source_symbol),
9941                namespace_alias: None,
9942                wildcard: false,
9943                line,
9944                byte_start: start,
9945                byte_end: end,
9946                dependencies: BTreeSet::new(),
9947            });
9948        }
9949    }
9950    SourceLessExportRefs {
9951        raw_refs,
9952        surface_parts,
9953    }
9954}
9955
9956fn build_dispatch_hints(
9957    rel_path: &str,
9958    data: &FileCallData,
9959    node_by_scoped: &HashMap<String, String>,
9960) -> Vec<DispatchHint> {
9961    let mut hints = Vec::new();
9962    let mut ordinal = 0usize;
9963    let mut calls_by_symbol = data.calls_by_symbol.iter().collect::<Vec<_>>();
9964    calls_by_symbol.sort_unstable_by(|left, right| left.0.cmp(right.0));
9965    for (caller_symbol, call_sites) in calls_by_symbol {
9966        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
9967            continue;
9968        };
9969        for call_site in call_sites {
9970            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
9971                continue;
9972            }
9973            ordinal += 1;
9974            hints.push(DispatchHint {
9975                id: ref_id(&[
9976                    rel_path,
9977                    "dispatch",
9978                    caller_symbol,
9979                    &call_site.line.to_string(),
9980                    &call_site.byte_start.to_string(),
9981                    &call_site.byte_end.to_string(),
9982                    &ordinal.to_string(),
9983                ]),
9984                method_name: call_site.callee_name.clone(),
9985                caller_node: caller_node.clone(),
9986                file: rel_path.to_string(),
9987                line: call_site.line,
9988                byte_start: call_site.byte_start,
9989                byte_end: call_site.byte_end,
9990            });
9991        }
9992    }
9993    hints
9994}
9995
9996fn surface_fingerprint(
9997    nodes: &mut [NodeRecord],
9998    data: &FileCallData,
9999    reexport_parts: &[String],
10000) -> String {
10001    nodes.sort_by(|left, right| {
10002        (left.file_path.as_str(), left.scoped_name.as_str())
10003            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
10004    });
10005    let mut parts = Vec::new();
10006    for node in nodes.iter() {
10007        parts.push(format!(
10008            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
10009            node.scoped_name,
10010            node.name,
10011            node.kind,
10012            node.exported,
10013            node.range.start_line,
10014            node.range.start_col,
10015            node.range.end_line,
10016            node.range.end_col,
10017            node.range_ordinal,
10018            node.signature.as_deref().unwrap_or("")
10019        ));
10020    }
10021    let mut exports = data.exported_symbols.clone();
10022    exports.sort();
10023    for export in exports {
10024        parts.push(format!("export\t{export}"));
10025    }
10026    if let Some(default_export) = &data.default_export_symbol {
10027        parts.push(format!("default\t{default_export}"));
10028    }
10029    let mut imports: Vec<String> = data
10030        .import_block
10031        .imports
10032        .iter()
10033        .map(|import| {
10034            format!(
10035                "import\t{}\t{:?}\t{}",
10036                import.module_path, import.form, import.raw_text
10037            )
10038        })
10039        .collect();
10040    imports.sort();
10041    parts.extend(imports);
10042    parts.extend(reexport_parts.iter().cloned());
10043    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
10044}
10045
10046fn resolve_ref<I: ResolverIndex>(raw: RawRef, index: &I) -> Result<ResolvedRef> {
10047    if !matches!(raw.kind.as_str(), "call" | "value_ref") {
10048        return Ok(ResolvedRef {
10049            dependencies: raw.dependencies.clone(),
10050            raw,
10051            status: "unresolved".to_string(),
10052            target_node: None,
10053            target_file: None,
10054            target_symbol: None,
10055            edge: None,
10056        });
10057    }
10058
10059    let caller_file = raw.caller_file.clone();
10060    let caller_data =
10061        index
10062            .caller_data(&caller_file)
10063            .ok_or_else(|| CallGraphStoreError::MissingCallerData {
10064                file: caller_file.clone(),
10065            })?;
10066    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
10067    let short_name = raw.short_name.as_deref().unwrap_or_default();
10068    let mut dependencies = raw.dependencies.clone();
10069
10070    let resolved = match index.lang_for(&caller_file) {
10071        Some(LangId::Rust) => {
10072            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data, &raw)
10073        }
10074        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
10075            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
10076        }
10077        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
10078    };
10079
10080    let Some((status, target_file, target_symbol)) = resolved else {
10081        return Ok(ResolvedRef {
10082            raw,
10083            status: "unresolved".to_string(),
10084            target_node: None,
10085            target_file: None,
10086            target_symbol: None,
10087            dependencies,
10088            edge: None,
10089        });
10090    };
10091
10092    dependencies.insert(target_file.clone());
10093    let target_node = index.node_for_symbol(&target_file, &target_symbol);
10094    if raw.kind == "value_ref"
10095        && !target_node
10096            .as_deref()
10097            .is_some_and(|node_id| index.node_is_callable(&target_file, node_id))
10098    {
10099        return Ok(ResolvedRef {
10100            raw,
10101            status: "unresolved".to_string(),
10102            target_node: None,
10103            target_file: None,
10104            target_symbol: None,
10105            dependencies,
10106            edge: None,
10107        });
10108    }
10109    let source_node = raw.caller_node.clone();
10110    let edge = if let Some(source_node) = source_node {
10111        if target_file == caller_file
10112            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
10113        {
10114            None
10115        } else {
10116            Some(EdgeRecord {
10117                edge_id: ref_id(&[&raw.ref_id, "edge"]),
10118                source_node,
10119                target_node: target_node.clone(),
10120                target_file: target_file.clone(),
10121                target_symbol: target_symbol.clone(),
10122                kind: raw.kind.clone(),
10123                line: raw.line,
10124            })
10125        }
10126    } else {
10127        None
10128    };
10129
10130    Ok(ResolvedRef {
10131        raw,
10132        status,
10133        target_node,
10134        target_file: Some(target_file),
10135        target_symbol: Some(target_symbol),
10136        dependencies,
10137        edge,
10138    })
10139}
10140
10141fn resolve_js_ts_target<I: ResolverIndex>(
10142    index: &I,
10143    caller_file: &str,
10144    full_ref: &str,
10145    short_name: &str,
10146    caller_data: &FileCallData,
10147) -> Option<(String, String, String)> {
10148    if let Some((namespace, member)) = full_ref.split_once('.') {
10149        for import in &caller_data.import_block.imports {
10150            if import.namespace_import.as_deref() == Some(namespace) {
10151                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10152                    if let Some((file, symbol)) =
10153                        resolve_exported_symbol(index, &target_file, member, 0)
10154                    {
10155                        return Some(("resolved".to_string(), file, symbol));
10156                    }
10157                }
10158            }
10159        }
10160    }
10161
10162    for import in &caller_data.import_block.imports {
10163        for spec in &import.names {
10164            if crate::imports::specifier_local_name(spec) == short_name {
10165                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10166                    let requested = crate::imports::specifier_imported_name(spec);
10167                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
10168                        .unwrap_or_else(|| (target_file, requested.to_string()));
10169                    return Some(("resolved".to_string(), file, symbol));
10170                }
10171            }
10172        }
10173
10174        if import.default_import.as_deref() == Some(short_name) {
10175            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10176                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
10177                    .or_else(|| {
10178                        index
10179                            .default_export(&target_file)
10180                            .map(|symbol| (target_file.clone(), symbol))
10181                    })
10182                    .unwrap_or_else(|| {
10183                        let file_name = Path::new(&target_file)
10184                            .file_name()
10185                            .and_then(|name| name.to_str())
10186                            .unwrap_or("unknown")
10187                            .to_string();
10188                        (target_file, format!("<default:{file_name}>"))
10189                    });
10190                return Some(("resolved".to_string(), file, symbol));
10191            }
10192        }
10193    }
10194
10195    for import in &caller_data.import_block.imports {
10196        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
10197            if index.has_export(&target_file, short_name) {
10198                return Some(("resolved".to_string(), target_file, short_name.to_string()));
10199            }
10200        }
10201    }
10202
10203    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10204}
10205
10206fn resolve_exported_symbol<I: ResolverIndex>(
10207    index: &I,
10208    file: &str,
10209    requested: &str,
10210    depth: usize,
10211) -> Option<(String, String)> {
10212    let mut visited = std::collections::HashMap::new();
10213    resolve_exported_symbol_inner(index, file, requested, depth, &mut visited)
10214}
10215
10216/// Re-export graphs are frequently cyclic (barrel files re-exporting each
10217/// other, `pub use` cycles). The depth cap alone bounds path LENGTH, not path
10218/// COUNT: with wildcard fan-out the walk explores branching^depth paths and a
10219/// single resolution can burn CPU-minutes. The memo prunes re-visits of a
10220/// (file, symbol) pair — but only when the earlier visit had at least as much
10221/// remaining depth budget (a shallower re-visit can reach leaves the deeper
10222/// first visit had to cut off at the cap, so plain visited-set pruning would
10223/// lose resolutions the capped walk finds).
10224fn resolve_exported_symbol_inner<I: ResolverIndex>(
10225    index: &I,
10226    file: &str,
10227    requested: &str,
10228    depth: usize,
10229    visited: &mut std::collections::HashMap<(String, String), usize>,
10230) -> Option<(String, String)> {
10231    if depth > 16 {
10232        return None;
10233    }
10234    if requested != "default" {
10235        if let Some(source_symbol) = index.export_alias(file, requested) {
10236            return Some((file.to_string(), source_symbol));
10237        }
10238        if index.has_export(file, requested) {
10239            return Some((file.to_string(), requested.to_string()));
10240        }
10241    } else if let Some(default) = index.default_export(file) {
10242        return Some((file.to_string(), default));
10243    }
10244
10245    // Memo check sits after the local-export fast paths: the common direct
10246    // hit never allocates the key, and a hit through the memo would have
10247    // returned above anyway.
10248    match visited.entry((file.to_string(), requested.to_string())) {
10249        std::collections::hash_map::Entry::Occupied(mut seen) => {
10250            if *seen.get() <= depth {
10251                return None;
10252            }
10253            seen.insert(depth);
10254        }
10255        std::collections::hash_map::Entry::Vacant(slot) => {
10256            slot.insert(depth);
10257        }
10258    }
10259
10260    for reexport in index.reexports_for(file) {
10261        let mut next_requested = requested.to_string();
10262        let matches = if reexport.wildcard {
10263            true
10264        } else if let Some(source_name) = reexport.named.get(requested) {
10265            next_requested = source_name.clone();
10266            true
10267        } else {
10268            false
10269        };
10270        if !matches {
10271            continue;
10272        }
10273        if let Some(target_file) = &reexport.target_file {
10274            if let Some(target) = resolve_exported_symbol_inner(
10275                index,
10276                target_file,
10277                &next_requested,
10278                depth + 1,
10279                visited,
10280            ) {
10281                return Some(target);
10282            }
10283        }
10284    }
10285    None
10286}
10287
10288fn resolve_rust_target<I: ResolverIndex>(
10289    index: &I,
10290    caller_file: &str,
10291    full_ref: &str,
10292    short_name: &str,
10293    caller_data: &FileCallData,
10294    raw: &RawRef,
10295) -> Option<(String, String, String)> {
10296    if full_ref.contains("::") {
10297        if let Some((target_file, target_symbol)) =
10298            rust_target_for_qualified(index, caller_file, full_ref, short_name, caller_data, raw)
10299        {
10300            return Some(("resolved".to_string(), target_file, target_symbol));
10301        }
10302    }
10303
10304    for import in &caller_data.import_block.imports {
10305        if let Some((target_file, target_symbol)) =
10306            rust_target_for_use(index, caller_file, import, short_name)
10307        {
10308            return Some(("resolved".to_string(), target_file, target_symbol));
10309        }
10310    }
10311
10312    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
10313}
10314
10315fn rust_target_for_qualified<I: ResolverIndex>(
10316    index: &I,
10317    caller_file: &str,
10318    full_ref: &str,
10319    short_name: &str,
10320    caller_data: &FileCallData,
10321    raw: &RawRef,
10322) -> Option<(String, String)> {
10323    let mut segments: Vec<&str> = full_ref.split("::").collect();
10324    if segments.len() < 2 {
10325        return None;
10326    }
10327    segments.pop();
10328    let requested_symbol = rust_target_symbol(full_ref, short_name);
10329
10330    for path in rust_module_path_candidates(&segments, caller_data, raw) {
10331        let path_refs = path.iter().map(String::as_str).collect::<Vec<_>>();
10332        if !matches!(path_refs.first().copied(), Some("crate" | "self" | "super")) {
10333            if let Some(target_file) = rust_workspace_file_for_segments(index, &path_refs) {
10334                return Some(rust_resolve_reexport_if_symbol_missing(
10335                    index,
10336                    target_file,
10337                    requested_symbol.clone(),
10338                ));
10339            }
10340        }
10341
10342        let module_segments = rust_resolve_segments_with_index(index, caller_file, &path_refs)?;
10343        if let Some(target) =
10344            rust_inline_scoped_target(index, caller_file, &module_segments, &requested_symbol)
10345        {
10346            return Some(target);
10347        }
10348        if let Some(target_file) = rust_file_for_segments(index, caller_file, &module_segments) {
10349            return Some(rust_resolve_reexport_if_symbol_missing(
10350                index,
10351                target_file,
10352                requested_symbol.clone(),
10353            ));
10354        }
10355    }
10356    None
10357}
10358
10359fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
10360    full_ref
10361        .rsplit("::")
10362        .next()
10363        .filter(|name| !name.is_empty())
10364        .unwrap_or(short_name)
10365        .to_string()
10366}
10367
10368fn rust_resolve_reexport_if_symbol_missing<I: ResolverIndex>(
10369    index: &I,
10370    target_file: String,
10371    target_symbol: String,
10372) -> (String, String) {
10373    if index
10374        .node_for_symbol(&target_file, &target_symbol)
10375        .is_some()
10376    {
10377        return (target_file, target_symbol);
10378    }
10379    if let Some(resolved) = resolve_exported_symbol(index, &target_file, &target_symbol, 0) {
10380        resolved
10381    } else {
10382        (target_file, target_symbol)
10383    }
10384}
10385
10386fn rust_module_path_candidates(
10387    segments: &[&str],
10388    caller_data: &FileCallData,
10389    raw: &RawRef,
10390) -> Vec<Vec<String>> {
10391    let mut candidates = Vec::new();
10392    if let Some(first) = segments.first().copied() {
10393        for import in &caller_data.import_block.imports {
10394            if !rust_import_is_visible_to_call(import, raw) {
10395                continue;
10396            }
10397            let Some((local_name, mut path_segments)) = rust_module_alias_segments(import) else {
10398                continue;
10399            };
10400            if local_name == first {
10401                path_segments.extend(segments[1..].iter().map(|segment| (*segment).to_string()));
10402                rust_push_unique_path_candidate(&mut candidates, path_segments);
10403            }
10404        }
10405    }
10406    rust_push_unique_path_candidate(
10407        &mut candidates,
10408        segments
10409            .iter()
10410            .map(|segment| (*segment).to_string())
10411            .collect(),
10412    );
10413    candidates
10414}
10415
10416fn rust_push_unique_path_candidate(candidates: &mut Vec<Vec<String>>, candidate: Vec<String>) {
10417    if !candidates.iter().any(|existing| existing == &candidate) {
10418        candidates.push(candidate);
10419    }
10420}
10421
10422fn rust_import_is_visible_to_call(import: &ImportStatement, raw: &RawRef) -> bool {
10423    import.byte_range.start <= raw.byte_start
10424}
10425
10426fn rust_module_alias_segments(import: &ImportStatement) -> Option<(String, Vec<String>)> {
10427    let path = import.module_path.trim().trim_end_matches(';').trim();
10428    if path.contains("::{") || path.contains('{') || path.contains('*') {
10429        return None;
10430    }
10431    let (path_without_alias, alias) = path
10432        .split_once(" as ")
10433        .map(|(left, right)| (left.trim(), Some(right.trim())))
10434        .unwrap_or((path, None));
10435    let segments = path_without_alias
10436        .split("::")
10437        .map(str::trim)
10438        .filter(|segment| !segment.is_empty())
10439        .collect::<Vec<_>>();
10440    let local_name = alias.or_else(|| segments.last().copied())?.to_string();
10441    if local_name.chars().next().is_some_and(char::is_uppercase) {
10442        return None;
10443    }
10444    Some((
10445        local_name,
10446        segments
10447            .into_iter()
10448            .map(|segment| segment.to_string())
10449            .collect(),
10450    ))
10451}
10452
10453fn rust_inline_scoped_target<I: ResolverIndex>(
10454    index: &I,
10455    caller_file: &str,
10456    module_segments: &[String],
10457    short_name: &str,
10458) -> Option<(String, String)> {
10459    index.inline_scoped_target(caller_file, module_segments, short_name)
10460}
10461
10462fn rust_target_for_use<I: ResolverIndex>(
10463    index: &I,
10464    caller_file: &str,
10465    import: &ImportStatement,
10466    short_name: &str,
10467) -> Option<(String, String)> {
10468    let path = import.module_path.trim().trim_end_matches(';');
10469    if let Some(brace_start) = path.find("::{") {
10470        let prefix = &path[..brace_start];
10471        if import.names.iter().any(|name| name == short_name) {
10472            let prefix_segments: Vec<&str> = prefix.split("::").collect();
10473            let module_segments =
10474                rust_resolve_segments_with_index(index, caller_file, &prefix_segments)?;
10475            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10476            return Some((file, short_name.to_string()));
10477        }
10478        return None;
10479    }
10480
10481    let (path_without_alias, alias) = path
10482        .split_once(" as ")
10483        .map(|(left, right)| (left.trim(), Some(right.trim())))
10484        .unwrap_or((path, None));
10485    let segments: Vec<&str> = path_without_alias.split("::").collect();
10486    let imported = alias.or_else(|| segments.last().copied())?;
10487    if imported != short_name {
10488        return None;
10489    }
10490    if segments.len() < 2 {
10491        return None;
10492    }
10493    let module_segments =
10494        rust_resolve_segments_with_index(index, caller_file, &segments[..segments.len() - 1])?;
10495    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
10496    Some((file, segments.last().unwrap_or(&short_name).to_string()))
10497}
10498
10499fn rust_workspace_file_for_segments<I: ResolverIndex>(
10500    index: &I,
10501    segments: &[&str],
10502) -> Option<String> {
10503    let crate_name = segments.first().copied()?;
10504    let src_prefix = index.crate_src_prefix(crate_name)?;
10505    let module_segments = segments[1..]
10506        .iter()
10507        .map(|segment| segment.to_string())
10508        .collect::<Vec<_>>();
10509    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
10510}
10511
10512#[cfg(test)]
10513static WORKSPACE_CRATE_PREFIX_BUILD_COUNTS: OnceLock<Mutex<HashMap<PathBuf, usize>>> =
10514    OnceLock::new();
10515
10516#[cfg(test)]
10517fn note_workspace_crate_prefix_build(project_root: &Path) {
10518    let mut counts = WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10519        .get_or_init(|| Mutex::new(HashMap::new()))
10520        .lock()
10521        .expect("workspace crate prefix build counts mutex poisoned");
10522    *counts.entry(project_root.to_path_buf()).or_default() += 1;
10523}
10524
10525#[cfg(not(test))]
10526fn note_workspace_crate_prefix_build(_project_root: &Path) {}
10527
10528#[cfg(test)]
10529fn reset_workspace_crate_prefix_build_count(project_root: &Path) {
10530    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10531        .get_or_init(|| Mutex::new(HashMap::new()))
10532        .lock()
10533        .expect("workspace crate prefix build counts mutex poisoned")
10534        .remove(project_root);
10535}
10536
10537#[cfg(test)]
10538fn workspace_crate_prefix_build_count(project_root: &Path) -> usize {
10539    WORKSPACE_CRATE_PREFIX_BUILD_COUNTS
10540        .get_or_init(|| Mutex::new(HashMap::new()))
10541        .lock()
10542        .expect("workspace crate prefix build counts mutex poisoned")
10543        .get(project_root)
10544        .copied()
10545        .unwrap_or(0)
10546}
10547
10548/// Walk the project tree once and map every Rust crate name (package name with
10549/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
10550/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
10551/// longer re-walks the filesystem once per ref.
10552fn build_workspace_crate_prefixes(
10553    project_root: &Path,
10554    facts: &FactPaths<'_>,
10555) -> HashMap<String, String> {
10556    note_workspace_crate_prefix_build(project_root);
10557    let mut prefixes = HashMap::new();
10558    let mut stack = vec![project_root.to_path_buf()];
10559    while let Some(dir) = stack.pop() {
10560        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
10561        if matches!(name, "target" | "node_modules" | ".git") {
10562            continue;
10563        }
10564        let manifest = dir.join("Cargo.toml");
10565        if facts.is_file(&manifest) {
10566            let crate_names = rust_manifest_crate_names(&manifest, facts);
10567            if !crate_names.is_empty() {
10568                let src_prefix = relative_path(
10569                    project_root,
10570                    &facts
10571                        .canonical(&dir.join("src"))
10572                        .unwrap_or_else(|| dir.join("src")),
10573                );
10574                for crate_name in crate_names {
10575                    prefixes
10576                        .entry(crate_name)
10577                        .or_insert_with(|| src_prefix.clone());
10578                }
10579            }
10580        }
10581        for entry in facts.list_dir(&dir) {
10582            if entry.kind == EntryKind::Directory {
10583                stack.push(dir.join(byte_path(&entry.name)));
10584            }
10585        }
10586    }
10587    prefixes
10588}
10589
10590/// Extract the crate names a manifest defines: the normalized package name
10591/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
10592/// reachable by either spelling, matching the previous match semantics.
10593fn rust_manifest_crate_names(manifest: &Path, facts: &FactPaths<'_>) -> Vec<String> {
10594    facts.config_fact(manifest, "manifest.name");
10595    facts.config_fact(manifest, "manifest.lib.name");
10596    let Some(bytes) = facts.attributed_bytes(manifest) else {
10597        return Vec::new();
10598    };
10599    let (package_name, lib_name) = rust_manifest_name_fields(&bytes);
10600    let mut names = Vec::new();
10601    if let Some(lib) = lib_name {
10602        names.push(lib);
10603    }
10604    if let Some(package) = package_name {
10605        let normalized = package.replace('-', "_");
10606        if !names.contains(&normalized) {
10607            names.push(normalized);
10608        }
10609    }
10610    names
10611}
10612
10613/// Preserve the manifest resolver's line-oriented extraction, including its
10614/// first non-lib name and last lib name semantics; this is not a TOML parser.
10615pub(crate) fn rust_manifest_name_fields(bytes: &[u8]) -> (Option<String>, Option<String>) {
10616    let Ok(source) = std::str::from_utf8(bytes) else {
10617        return (None, None);
10618    };
10619    let mut in_lib = false;
10620    let mut package_name = None;
10621    let mut lib_name = None;
10622    for line in source.lines() {
10623        let trimmed = line.trim();
10624        if trimmed.starts_with('[') {
10625            in_lib = trimmed == "[lib]";
10626            continue;
10627        }
10628        let Some((key, value)) = trimmed.split_once('=') else {
10629            continue;
10630        };
10631        let key = key.trim();
10632        let value = value.trim().trim_matches('"');
10633        if in_lib && key == "name" {
10634            lib_name = Some(value.to_string());
10635        } else if !in_lib && key == "name" && package_name.is_none() {
10636            package_name = Some(value.to_string());
10637        }
10638    }
10639    (package_name, lib_name)
10640}
10641
10642fn rust_resolve_segments_with_index<I: ResolverIndex>(
10643    index: &I,
10644    caller_file: &str,
10645    segments: &[&str],
10646) -> Option<Vec<String>> {
10647    let caller_segments = if index.rust_crate_root_file(caller_file).as_deref() == Some(caller_file)
10648    {
10649        Vec::new()
10650    } else {
10651        rust_registered_module_segments(index, caller_file)
10652            .unwrap_or_else(|| rust_module_segments_for_rel(caller_file))
10653    };
10654    rust_resolve_segments_from(caller_segments, segments)
10655}
10656
10657fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
10658    rust_resolve_segments_from(rust_module_segments_for_rel(caller_file), segments)
10659}
10660
10661fn rust_resolve_segments_from(
10662    caller_segments: Vec<String>,
10663    segments: &[&str],
10664) -> Option<Vec<String>> {
10665    if segments.is_empty() {
10666        return Some(Vec::new());
10667    }
10668    match segments[0] {
10669        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
10670        "self" => {
10671            let mut resolved = caller_segments;
10672            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10673            Some(resolved)
10674        }
10675        "super" => {
10676            let mut resolved = caller_segments;
10677            resolved.pop();
10678            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
10679            Some(resolved)
10680        }
10681        _ => {
10682            let mut resolved = caller_segments;
10683            resolved.pop();
10684            resolved.extend(segments.iter().map(|item| item.to_string()));
10685            Some(resolved)
10686        }
10687    }
10688}
10689
10690fn rust_registered_module_segments<I: ResolverIndex>(
10691    index: &I,
10692    caller_file: &str,
10693) -> Option<Vec<String>> {
10694    let mut current = caller_file.to_string();
10695    let mut segments = Vec::new();
10696    let mut seen = HashSet::new();
10697    while seen.insert(current.clone()) {
10698        let Some((parent, module)) = index.module_parent(&current) else {
10699            break;
10700        };
10701        segments.push(module);
10702        current = parent;
10703    }
10704    if segments.is_empty() {
10705        None
10706    } else {
10707        segments.reverse();
10708        Some(segments)
10709    }
10710}
10711
10712fn rust_file_for_segments<I: ResolverIndex>(
10713    index: &I,
10714    caller_file: &str,
10715    segments: &[String],
10716) -> Option<String> {
10717    let src_prefix = rust_src_prefix(caller_file);
10718    if let Some(target) =
10719        rust_file_from_module_declarations(index, caller_file, &src_prefix, segments)
10720    {
10721        return Some(target);
10722    }
10723    rust_file_for_src_prefix(index, &src_prefix, segments)
10724}
10725
10726fn rust_file_from_module_declarations<I: ResolverIndex>(
10727    index: &I,
10728    caller_file: &str,
10729    src_prefix: &str,
10730    segments: &[String],
10731) -> Option<String> {
10732    let mut current = index.rust_crate_root_file(caller_file).or_else(|| {
10733        [
10734            format!("{src_prefix}/lib.rs"),
10735            format!("{src_prefix}/main.rs"),
10736        ]
10737        .into_iter()
10738        .find(|candidate| index.contains_file(candidate))
10739    })?;
10740    for segment in segments {
10741        current = index.module_target(&current, segment)?;
10742    }
10743    Some(current)
10744}
10745
10746fn rust_file_for_src_prefix<I: ResolverIndex>(
10747    index: &I,
10748    src_prefix: &str,
10749    segments: &[String],
10750) -> Option<String> {
10751    let candidate = if segments.is_empty() {
10752        [src_prefix, "lib.rs"].join("/")
10753    } else {
10754        format!("{}/{}.rs", src_prefix, segments.join("/"))
10755    };
10756    if index.contains_file(&candidate) {
10757        return Some(candidate);
10758    }
10759    if !segments.is_empty() {
10760        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
10761        if index.contains_file(&mod_candidate) {
10762            return Some(mod_candidate);
10763        }
10764    }
10765    None
10766}
10767
10768fn rust_src_prefix(rel_path: &str) -> String {
10769    rel_path
10770        .split_once("/src/")
10771        .map(|(prefix, _)| format!("{prefix}/src"))
10772        .unwrap_or_else(|| "src".to_string())
10773}
10774
10775fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
10776    let after_src = rel_path
10777        .split_once("/src/")
10778        .map(|(_, rest)| rest)
10779        .or_else(|| rel_path.strip_prefix("src/"))
10780        .unwrap_or(rel_path);
10781    if matches!(after_src, "lib.rs" | "main.rs") {
10782        return Vec::new();
10783    }
10784    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
10785        return prefix.split('/').map(|item| item.to_string()).collect();
10786    }
10787    after_src
10788        .strip_suffix(".rs")
10789        .unwrap_or(after_src)
10790        .split('/')
10791        .map(|item| item.to_string())
10792        .collect()
10793}
10794
10795fn resolve_local_target<I: ResolverIndex>(
10796    _index: &I,
10797    caller_file: &str,
10798    full_ref: &str,
10799    short_name: &str,
10800    caller_data: &FileCallData,
10801) -> Option<(String, String, String)> {
10802    if !callgraph::is_bare_callee(full_ref, short_name) {
10803        return None;
10804    }
10805    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
10806        .ok()
10807        .map(|symbol| {
10808            (
10809                "resolved_local".to_string(),
10810                caller_file.to_string(),
10811                symbol,
10812            )
10813        })
10814}
10815
10816impl<'a> ProjectIndex<'a> {
10817    fn from_parts(
10818        project_root: &Path,
10819        files: HashMap<String, DbFileIndex>,
10820        caller_data: HashMap<String, &'a FileCallData>,
10821        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10822        facts: Rc<dyn ProjectFacts + 'a>,
10823    ) -> Self {
10824        Self {
10825            facts,
10826            unbound_non_utf8_paths: Vec::new(),
10827            project_root: project_root.to_path_buf(),
10828            files,
10829            caller_data,
10830            workspace_crate_prefixes,
10831            rust_crate_roots: callgraph::RustCrateRootMemo::default(),
10832        }
10833    }
10834
10835    fn from_db_and_callers(
10836        tx: &Transaction<'_>,
10837        project_root: &Path,
10838        caller_extracts: &'a HashMap<String, FileExtract>,
10839        workspace_crate_prefixes: WorkspaceCratePrefixCache,
10840    ) -> Result<Self> {
10841        // Incremental refreshes get a fresh snapshot memo so a watcher rewrite can
10842        // never observe declarations retained by an earlier refresh generation.
10843        let module_resolution_memo = callgraph::ModuleResolutionMemo::default();
10844        let disk = DiskFacts::new(project_root);
10845        let facts = FactPaths {
10846            root: project_root,
10847            facts: &disk,
10848        };
10849        let mut files = load_db_file_indexes(tx, project_root, &module_resolution_memo, &facts)?;
10850        let mut caller_data = HashMap::new();
10851        for (rel_path, extract) in caller_extracts {
10852            files.insert(
10853                rel_path.clone(),
10854                DbFileIndex::from_extract(
10855                    project_root,
10856                    extract,
10857                    &FactPaths {
10858                        root: project_root,
10859                        facts: &DiskFacts::new(project_root),
10860                    },
10861                ),
10862            );
10863            caller_data.insert(rel_path.clone(), &extract.data);
10864        }
10865        Ok(Self::from_parts(
10866            project_root,
10867            files,
10868            caller_data,
10869            workspace_crate_prefixes,
10870            Rc::new(DiskFacts::new(project_root)),
10871        ))
10872    }
10873
10874    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
10875        self.files.get(rel_path).and_then(|file| file.lang)
10876    }
10877
10878    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
10879        self.files
10880            .get(caller_file)
10881            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
10882    }
10883
10884    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
10885        self.files
10886            .get(rel_path)
10887            .map(|file| file.reexports.as_slice())
10888            .unwrap_or(&[])
10889    }
10890
10891    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
10892        self.files.get(rel_path).and_then(|file| {
10893            file.node_by_scoped
10894                .get(symbol)
10895                .cloned()
10896                .or_else(|| file.node_by_bare.get(symbol).cloned())
10897        })
10898    }
10899
10900    fn node_is_callable(&self, rel_path: &str, node_id: &str) -> bool {
10901        self.files
10902            .get(rel_path)
10903            .and_then(|file| file.node_kind_by_id.get(node_id))
10904            .is_some_and(|kind| matches!(kind.as_str(), "function" | "kernel" | "method"))
10905    }
10906}
10907
10908impl DbFileIndex {
10909    fn from_extract(project_root: &Path, extract: &FileExtract, facts: &FactPaths<'_>) -> Self {
10910        let mut node_by_scoped = HashMap::new();
10911        let mut node_by_bare = HashMap::new();
10912        for node in &extract.nodes {
10913            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
10914            node_by_bare
10915                .entry(node.name.clone())
10916                .or_insert(node.id.clone());
10917        }
10918        let node_kind_by_id = extract
10919            .nodes
10920            .iter()
10921            .map(|node| (node.id.clone(), node.kind.clone()))
10922            .collect();
10923        let mut export_aliases = HashMap::new();
10924        for raw_ref in &extract.raw_refs {
10925            if raw_ref.kind == "export_alias" {
10926                if let (Some(exported), Some(source_symbol)) =
10927                    (&raw_ref.local_name, &raw_ref.requested_name)
10928                {
10929                    export_aliases.insert(exported.clone(), source_symbol.clone());
10930                }
10931            }
10932        }
10933        let mut module_targets = HashMap::new();
10934        let mut declared_module_targets = HashMap::new();
10935        let mut reexports = Vec::new();
10936        for raw_ref in &extract.raw_refs {
10937            if !matches!(raw_ref.kind.as_str(), "import" | "reexport" | "module") {
10938                continue;
10939            }
10940            let Some(module_path) = &raw_ref.module_path else {
10941                continue;
10942            };
10943            let target_file =
10944                module_target_from_dependencies(project_root, &raw_ref.dependencies, facts);
10945            module_targets
10946                .entry(module_path.clone())
10947                .or_insert_with(|| target_file.clone());
10948            if raw_ref.kind == "module" {
10949                declared_module_targets
10950                    .entry(module_path.clone())
10951                    .or_insert_with(|| target_file.clone());
10952            }
10953            if raw_ref.kind == "reexport" {
10954                reexports.push(reexport_index_from_raw(raw_ref, target_file));
10955            }
10956        }
10957        Self {
10958            lang: Some(extract.lang),
10959            exports: extract.data.exported_symbols.iter().cloned().collect(),
10960            default_export: extract.data.default_export_symbol.clone(),
10961            export_aliases,
10962            node_by_scoped,
10963            node_by_bare,
10964            node_kind_by_id,
10965            module_targets,
10966            declared_module_targets,
10967            reexports,
10968        }
10969    }
10970}
10971
10972fn load_db_file_indexes(
10973    tx: &Transaction<'_>,
10974    project_root: &Path,
10975    module_resolution_memo: &callgraph::ModuleResolutionMemo,
10976    facts: &FactPaths<'_>,
10977) -> Result<HashMap<String, DbFileIndex>> {
10978    let mut files = HashMap::new();
10979    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
10980    let rows = stmt.query_map([], |row| {
10981        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10982    })?;
10983    for row in rows {
10984        let (rel_path, lang) = row?;
10985        files.insert(
10986            rel_path.clone(),
10987            DbFileIndex {
10988                lang: lang_from_label(&lang),
10989                exports: HashSet::new(),
10990                default_export: None,
10991                export_aliases: HashMap::new(),
10992                node_by_scoped: HashMap::new(),
10993                node_by_bare: HashMap::new(),
10994                node_kind_by_id: HashMap::new(),
10995                module_targets: HashMap::new(),
10996                declared_module_targets: HashMap::new(),
10997                reexports: Vec::new(),
10998            },
10999        );
11000    }
11001
11002    let mut node_stmt = tx.prepare(
11003        "SELECT file_path, id, name, scoped_name, kind, exported, is_default_export FROM nodes",
11004    )?;
11005    let nodes = node_stmt.query_map([], |row| {
11006        Ok((
11007            row.get::<_, String>(0)?,
11008            row.get::<_, String>(1)?,
11009            row.get::<_, String>(2)?,
11010            row.get::<_, String>(3)?,
11011            row.get::<_, String>(4)?,
11012            row.get::<_, i64>(5)? != 0,
11013            row.get::<_, i64>(6)? != 0,
11014        ))
11015    })?;
11016    for row in nodes {
11017        let (file_path, id, name, scoped_name, kind, exported, is_default_export) = row?;
11018        let file = files
11019            .entry(file_path.clone())
11020            .or_insert_with(|| DbFileIndex {
11021                lang: None,
11022                exports: HashSet::new(),
11023                default_export: None,
11024                export_aliases: HashMap::new(),
11025                node_by_scoped: HashMap::new(),
11026                node_by_bare: HashMap::new(),
11027                node_kind_by_id: HashMap::new(),
11028                module_targets: HashMap::new(),
11029                declared_module_targets: HashMap::new(),
11030                reexports: Vec::new(),
11031            });
11032        if exported {
11033            file.exports.insert(name.clone());
11034            file.exports.insert(scoped_name.clone());
11035        }
11036        if is_default_export {
11037            file.default_export = Some(scoped_name.clone());
11038        }
11039        file.node_by_scoped.insert(scoped_name, id.clone());
11040        file.node_by_bare.entry(name).or_insert(id.clone());
11041        file.node_kind_by_id.insert(id, kind);
11042    }
11043    let file_keys: HashSet<String> = files.keys().cloned().collect();
11044    // Persisted caller extracts supply import targets. Only reexports from other
11045    // files need dependency reconstruction, and their caller dependencies are
11046    // loaded once instead of issuing repeated SQLite queries per reference.
11047    let dependencies_by_file = load_file_dependencies_index(tx)?;
11048    let mut ref_stmt = tx.prepare(
11049        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
11050             FROM refs WHERE kind IN ('module', 'reexport', 'export_alias')",
11051    )?;
11052    let ref_rows = ref_stmt.query_map([], |row| {
11053        Ok((
11054            row.get::<_, String>(0)?,
11055            row.get::<_, String>(1)?,
11056            row.get::<_, String>(2)?,
11057            row.get::<_, Option<String>>(3)?,
11058            row.get::<_, Option<String>>(4)?,
11059            row.get::<_, i64>(5)? != 0,
11060            row.get::<_, Option<String>>(6)?,
11061            row.get::<_, Option<String>>(7)?,
11062        ))
11063    })?;
11064    for row in ref_rows {
11065        let (
11066            ref_id,
11067            caller_file,
11068            kind,
11069            module_path,
11070            full_ref,
11071            wildcard,
11072            local_name,
11073            requested_name,
11074        ) = row?;
11075        if kind == "export_alias" {
11076            if let (Some(exported), Some(source_symbol), Some(file)) =
11077                (local_name, requested_name, files.get_mut(&caller_file))
11078            {
11079                file.export_aliases.insert(exported, source_symbol);
11080            }
11081            continue;
11082        }
11083        let Some(module_path) = module_path else {
11084            continue;
11085        };
11086        let file_deps = dependencies_by_file
11087            .get(&caller_file)
11088            .cloned()
11089            .unwrap_or_default();
11090        let deps = stored_dependencies_for_module(
11091            project_root,
11092            &caller_file,
11093            &module_path,
11094            &file_deps,
11095            &file_keys,
11096            facts,
11097        );
11098        let target_file = if kind == "module" {
11099            rust_declared_module_target(
11100                project_root,
11101                &caller_file,
11102                &module_path,
11103                module_resolution_memo,
11104                facts,
11105            )
11106        } else {
11107            deps.iter().find(|dep| file_keys.contains(*dep)).map(|dep| {
11108                relative_path(
11109                    project_root,
11110                    &facts
11111                        .canonical(&project_root.join(dep))
11112                        .unwrap_or_else(|| project_root.join(dep)),
11113                )
11114            })
11115        };
11116        if let Some(file) = files.get_mut(&caller_file) {
11117            file.module_targets
11118                .entry(module_path.clone())
11119                .or_insert_with(|| target_file.clone());
11120            if kind == "module" {
11121                file.declared_module_targets
11122                    .entry(module_path.clone())
11123                    .or_insert_with(|| target_file.clone());
11124            }
11125            if kind == "reexport" {
11126                let raw = RawRef {
11127                    ref_id,
11128                    caller_node: None,
11129                    caller_symbol: None,
11130                    caller_file,
11131                    kind,
11132                    short_name: None,
11133                    full_ref,
11134                    module_path: Some(module_path),
11135                    import_kind: Some("reexport".to_string()),
11136                    local_name: None,
11137                    requested_name: None,
11138                    namespace_alias: None,
11139                    wildcard,
11140                    line: 0,
11141                    byte_start: 0,
11142                    byte_end: 0,
11143                    dependencies: deps,
11144                };
11145                file.reexports
11146                    .push(reexport_index_from_raw(&raw, target_file));
11147            }
11148        }
11149    }
11150
11151    Ok(files)
11152}
11153
11154fn stored_dependencies_for_module(
11155    project_root: &Path,
11156    caller_file: &str,
11157    module_path: &str,
11158    caller_dependencies: &BTreeSet<String>,
11159    indexed_files: &HashSet<String>,
11160    facts: &FactPaths<'_>,
11161) -> BTreeSet<String> {
11162    let caller_path = project_root.join(caller_file);
11163    let mut candidates = rust_module_dependencies(project_root, &caller_path, module_path, facts);
11164    if module_path.starts_with('.') {
11165        let caller_dir = caller_path.parent().unwrap_or(project_root);
11166        for candidate in relative_module_candidates(&caller_dir.join(module_path)) {
11167            let normalized = if facts.is_file(&candidate) {
11168                facts.canonical(&candidate).unwrap_or(candidate.clone())
11169            } else {
11170                candidate
11171            };
11172            candidates.insert(relative_path(project_root, &normalized));
11173        }
11174    }
11175    let exact = candidates
11176        .intersection(caller_dependencies)
11177        .filter(|dependency| indexed_files.contains(*dependency))
11178        .cloned()
11179        .collect::<BTreeSet<_>>();
11180    if !exact.is_empty() || module_path.starts_with('.') {
11181        return exact;
11182    }
11183
11184    let module_path = rust_module_path_without_alias_or_use_list(module_path)
11185        .trim_matches(|character| matches!(character, '\'' | '"'));
11186    let package_name = module_path
11187        .split('/')
11188        .next_back()
11189        .unwrap_or(module_path)
11190        .replace('_', "-");
11191    let matched = caller_dependencies
11192        .iter()
11193        .filter(|dependency| indexed_files.contains(*dependency))
11194        .filter(|dependency| {
11195            dependency.as_str() == module_path
11196                || dependency.ends_with(&format!("/{module_path}"))
11197                || Path::new(dependency).components().any(|component| {
11198                    component.as_os_str().to_string_lossy().replace('_', "-") == package_name
11199                })
11200        })
11201        .cloned()
11202        .collect::<BTreeSet<_>>();
11203    if matched.len() == 1 {
11204        matched
11205    } else {
11206        BTreeSet::new()
11207    }
11208}
11209
11210fn load_file_dependencies_index(tx: &Transaction<'_>) -> Result<HashMap<String, BTreeSet<String>>> {
11211    let mut by_file: HashMap<String, BTreeSet<String>> = HashMap::new();
11212    let mut stmt = tx.prepare("SELECT file_path, dep_file FROM file_dependencies")?;
11213    let rows = stmt.query_map([], |row| {
11214        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11215    })?;
11216    for row in rows {
11217        let (file_path, dependency) = row?;
11218        by_file.entry(file_path).or_default().insert(dependency);
11219    }
11220    Ok(by_file)
11221}
11222
11223struct ColdBuildInsertStatements<'stmt> {
11224    file: Statement<'stmt>,
11225    node: Statement<'stmt>,
11226    file_dependency: Statement<'stmt>,
11227    dispatch_hint: Statement<'stmt>,
11228    backend_state: Statement<'stmt>,
11229    reference: Statement<'stmt>,
11230    staging_ref_context: Statement<'stmt>,
11231    edge: Statement<'stmt>,
11232}
11233
11234impl<'stmt> ColdBuildInsertStatements<'stmt> {
11235    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
11236        Ok(Self {
11237            file: tx.prepare(
11238                "INSERT OR REPLACE INTO files(
11239                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11240                    is_public_api, surface_fingerprint, indexed_at
11241                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11242            )?,
11243            node: tx.prepare(
11244                "INSERT OR REPLACE INTO nodes(
11245                    id, file_path, name, scoped_name, kind, start_line, start_col,
11246                    end_line, end_col, range_ordinal, signature, exported,
11247                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
11248                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11249            )?,
11250            file_dependency: tx.prepare(
11251                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11252            )?,
11253            dispatch_hint: tx.prepare(
11254                "INSERT OR REPLACE INTO dispatch_hints(
11255                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11256                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11257            )?,
11258            backend_state: tx.prepare(
11259                "INSERT OR REPLACE INTO backend_file_state(
11260                    backend, workspace_root, file_path, content_hash, status, updated_at
11261                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
11262            )?,
11263            reference: tx.prepare(
11264                "INSERT OR REPLACE INTO refs(
11265                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11266                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11267                    byte_start, byte_end, status, target_node, target_file, target_symbol,
11268                    provenance
11269                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11270            )?,
11271            staging_ref_context: tx.prepare(
11272                "INSERT OR REPLACE INTO staging_ref_context(ref_id, caller_symbol) VALUES(?1, ?2)",
11273            )?,
11274            edge: tx.prepare(
11275                "INSERT OR REPLACE INTO edges(
11276                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11277                    kind, line, provenance
11278                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11279            )?,
11280        })
11281    }
11282}
11283
11284fn insert_file_extract_prepared(
11285    statements: &mut ColdBuildInsertStatements<'_>,
11286    workspace_root: &str,
11287    extract: &FileExtract,
11288) -> Result<()> {
11289    statements.file.execute(params![
11290        extract.rel_path,
11291        hash_to_hex(extract.freshness.content_hash),
11292        system_time_to_ns(extract.freshness.mtime),
11293        extract.freshness.size as i64,
11294        lang_label(extract.lang),
11295        extract.surface_fingerprint,
11296        unix_seconds_now(),
11297    ])?;
11298    for node in &extract.nodes {
11299        statements.node.execute(params![
11300            node.id,
11301            node.file_path,
11302            node.name,
11303            node.scoped_name,
11304            node.kind,
11305            node.range.start_line as i64,
11306            node.range.start_col as i64,
11307            node.range.end_line as i64,
11308            node.range.end_col as i64,
11309            node.range_ordinal as i64,
11310            node.signature,
11311            bool_int(node.exported),
11312            bool_int(node.is_default_export),
11313            bool_int(node.is_type_like),
11314            bool_int(node.is_callgraph_entry_point),
11315            PROVENANCE_TREESITTER,
11316        ])?;
11317    }
11318
11319    let mut dependencies = BTreeSet::new();
11320    for raw_ref in &extract.raw_refs {
11321        dependencies.extend(raw_ref.dependencies.iter().cloned());
11322    }
11323    for dep_file in &dependencies {
11324        statements
11325            .file_dependency
11326            .execute(params![extract.rel_path, dep_file])?;
11327    }
11328
11329    for hint in &extract.dispatch_hints {
11330        statements.dispatch_hint.execute(params![
11331            hint.id,
11332            hint.method_name,
11333            hint.caller_node,
11334            hint.file,
11335            hint.line as i64,
11336            hint.byte_start as i64,
11337            hint.byte_end as i64,
11338            PROVENANCE_TREESITTER,
11339        ])?;
11340    }
11341    insert_backend_state_prepared(
11342        &mut statements.backend_state,
11343        workspace_root,
11344        &extract.rel_path,
11345        Some(&extract.freshness.content_hash),
11346        "fresh",
11347    )?;
11348    Ok(())
11349}
11350
11351fn insert_backend_state_prepared(
11352    stmt: &mut Statement<'_>,
11353    workspace_root: &str,
11354    rel_path: &str,
11355    content_hash: Option<&blake3::Hash>,
11356    status: &str,
11357) -> Result<()> {
11358    let hash = content_hash
11359        .map(|hash| hash_to_hex(*hash))
11360        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
11361    stmt.execute(params![
11362        BACKEND_TREESITTER,
11363        workspace_root,
11364        rel_path,
11365        hash,
11366        status,
11367        unix_seconds_now(),
11368    ])?;
11369    Ok(())
11370}
11371
11372fn insert_staged_ref_prepared(
11373    statements: &mut ColdBuildInsertStatements<'_>,
11374    raw: &RawRef,
11375) -> Result<()> {
11376    statements.reference.execute(params![
11377        raw.ref_id,
11378        raw.caller_node,
11379        raw.caller_file,
11380        raw.kind,
11381        raw.short_name,
11382        raw.full_ref,
11383        raw.module_path,
11384        raw.import_kind,
11385        raw.local_name,
11386        raw.requested_name,
11387        raw.namespace_alias,
11388        bool_int(raw.wildcard),
11389        raw.line as i64,
11390        raw.byte_start as i64,
11391        raw.byte_end as i64,
11392        "staged",
11393        Option::<String>::None,
11394        Option::<String>::None,
11395        Option::<String>::None,
11396        ref_provenance(raw),
11397    ])?;
11398    statements
11399        .staging_ref_context
11400        .execute(params![raw.ref_id, raw.caller_symbol])?;
11401    Ok(())
11402}
11403
11404fn insert_resolved_ref_prepared(
11405    statements: &mut ColdBuildInsertStatements<'_>,
11406    resolved: &ResolvedRef,
11407) -> Result<()> {
11408    let raw = &resolved.raw;
11409    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11410    statements.reference.execute(params![
11411        raw.ref_id,
11412        raw.caller_node,
11413        raw.caller_file,
11414        raw.kind,
11415        raw.short_name,
11416        raw.full_ref,
11417        raw.module_path,
11418        raw.import_kind,
11419        raw.local_name,
11420        raw.requested_name,
11421        raw.namespace_alias,
11422        bool_int(raw.wildcard),
11423        raw.line as i64,
11424        raw.byte_start as i64,
11425        raw.byte_end as i64,
11426        resolved.status,
11427        resolved.target_node,
11428        resolved.target_file,
11429        resolved.target_symbol,
11430        ref_provenance(raw),
11431    ])?;
11432    if let Some(edge) = &resolved.edge {
11433        statements.edge.execute(params![
11434            edge.edge_id,
11435            raw.ref_id,
11436            edge.source_node,
11437            edge.target_node,
11438            edge.target_file,
11439            edge.target_symbol,
11440            edge.kind,
11441            edge.line as i64,
11442            ref_provenance(raw),
11443        ])?;
11444    }
11445    Ok(())
11446}
11447
11448#[cfg(test)]
11449fn insert_file_extract(
11450    tx: &Transaction<'_>,
11451    project_root: &Path,
11452    extract: &FileExtract,
11453) -> Result<()> {
11454    tx.execute(
11455        "INSERT OR REPLACE INTO files(
11456            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
11457            is_public_api, surface_fingerprint, indexed_at
11458        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
11459        params![
11460            extract.rel_path,
11461            hash_to_hex(extract.freshness.content_hash),
11462            system_time_to_ns(extract.freshness.mtime),
11463            extract.freshness.size as i64,
11464            lang_label(extract.lang),
11465            extract.surface_fingerprint,
11466            unix_seconds_now(),
11467        ],
11468    )?;
11469    for node in &extract.nodes {
11470        tx.execute(
11471            "INSERT OR REPLACE INTO nodes(
11472                id, file_path, name, scoped_name, kind, start_line, start_col,
11473                end_line, end_col, range_ordinal, signature, exported,
11474                is_default_export, is_type_like, is_callgraph_entry_point, provenance
11475            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
11476            params![
11477                node.id,
11478                node.file_path,
11479                node.name,
11480                node.scoped_name,
11481                node.kind,
11482                node.range.start_line as i64,
11483                node.range.start_col as i64,
11484                node.range.end_line as i64,
11485                node.range.end_col as i64,
11486                node.range_ordinal as i64,
11487                node.signature,
11488                bool_int(node.exported),
11489                bool_int(node.is_default_export),
11490                bool_int(node.is_type_like),
11491                bool_int(node.is_callgraph_entry_point),
11492                PROVENANCE_TREESITTER,
11493            ],
11494        )?;
11495    }
11496    let mut dependencies = BTreeSet::new();
11497    for raw_ref in &extract.raw_refs {
11498        dependencies.extend(raw_ref.dependencies.iter().cloned());
11499    }
11500    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
11501
11502    for hint in &extract.dispatch_hints {
11503        tx.execute(
11504            "INSERT OR REPLACE INTO dispatch_hints(
11505                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
11506            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11507            params![
11508                hint.id,
11509                hint.method_name,
11510                hint.caller_node,
11511                hint.file,
11512                hint.line as i64,
11513                hint.byte_start as i64,
11514                hint.byte_end as i64,
11515                PROVENANCE_TREESITTER,
11516            ],
11517        )?;
11518    }
11519    mark_backend_state(
11520        tx,
11521        project_root,
11522        &extract.rel_path,
11523        Some(&extract.freshness.content_hash),
11524        "fresh",
11525    )?;
11526    Ok(())
11527}
11528
11529#[cfg(test)]
11530fn insert_file_dependencies(
11531    tx: &Transaction<'_>,
11532    file_path: &str,
11533    dependencies: &BTreeSet<String>,
11534) -> Result<()> {
11535    for dep_file in dependencies {
11536        tx.execute(
11537            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
11538            params![file_path, dep_file],
11539        )?;
11540    }
11541    Ok(())
11542}
11543
11544fn ref_provenance(raw: &RawRef) -> &'static str {
11545    if raw.kind == "value_ref" {
11546        PROVENANCE_VALUE_REF
11547    } else {
11548        PROVENANCE_TREESITTER
11549    }
11550}
11551
11552#[cfg(test)]
11553fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
11554    let raw = &resolved.raw;
11555    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
11556    tx.execute(
11557        "INSERT OR REPLACE INTO refs(
11558            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
11559            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
11560            byte_start, byte_end, status, target_node, target_file, target_symbol,
11561            provenance
11562        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
11563        params![
11564            raw.ref_id,
11565            raw.caller_node,
11566            raw.caller_file,
11567            raw.kind,
11568            raw.short_name,
11569            raw.full_ref,
11570            raw.module_path,
11571            raw.import_kind,
11572            raw.local_name,
11573            raw.requested_name,
11574            raw.namespace_alias,
11575            bool_int(raw.wildcard),
11576            raw.line as i64,
11577            raw.byte_start as i64,
11578            raw.byte_end as i64,
11579            resolved.status,
11580            resolved.target_node,
11581            resolved.target_file,
11582            resolved.target_symbol,
11583            ref_provenance(raw),
11584        ],
11585    )?;
11586    if let Some(edge) = &resolved.edge {
11587        tx.execute(
11588            "INSERT OR REPLACE INTO edges(
11589                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11590                kind, line, provenance
11591            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
11592            params![
11593                edge.edge_id,
11594                raw.ref_id,
11595                edge.source_node,
11596                edge.target_node,
11597                edge.target_file,
11598                edge.target_symbol,
11599                edge.kind,
11600                edge.line as i64,
11601                ref_provenance(raw),
11602            ],
11603        )?;
11604    }
11605    Ok(())
11606}
11607
11608fn insert_method_dispatch_edges(
11609    tx: &Transaction<'_>,
11610    project_root: &Path,
11611    caller_files: Option<&BTreeSet<String>>,
11612) -> Result<usize> {
11613    let references = load_name_match_refs(tx, caller_files)?;
11614    if references.is_empty() {
11615        return Ok(0);
11616    }
11617
11618    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
11619    let mut source_cache: DispatchSourceCache = HashMap::new();
11620    let mut inserted = 0usize;
11621    for reference in references {
11622        let key = (reference.method_name.clone(), reference.lang.clone());
11623        let candidates = match candidates_by_name.entry(key) {
11624            Entry::Occupied(entry) => entry.into_mut(),
11625            Entry::Vacant(entry) => {
11626                let candidates =
11627                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
11628                entry.insert(candidates)
11629            }
11630        };
11631
11632        match infer_receiver_type_state(project_root, &reference, &mut source_cache) {
11633            ReceiverTypeInference::Known(receiver_type) => {
11634                let Some(candidate) =
11635                    select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
11636                else {
11637                    continue;
11638                };
11639                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11640                inserted += 1;
11641                continue;
11642            }
11643            ReceiverTypeInference::RustDirectSelfField {
11644                receiver_type,
11645                declaration_file,
11646                module_scope,
11647            } => {
11648                let Some(candidate) = select_rust_direct_self_field_candidate(
11649                    project_root,
11650                    &reference,
11651                    candidates.as_slice(),
11652                    &receiver_type,
11653                    &declaration_file,
11654                    &module_scope,
11655                    &mut source_cache,
11656                ) else {
11657                    continue;
11658                };
11659                insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
11660                inserted += 1;
11661                continue;
11662            }
11663            ReceiverTypeInference::KnownButUnresolved => continue,
11664            ReceiverTypeInference::Unknown => {}
11665        }
11666
11667        if method_name_match_denylisted(&reference.method_name) {
11668            continue;
11669        }
11670
11671        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
11672            continue;
11673        };
11674        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
11675        inserted += 1;
11676    }
11677    Ok(inserted)
11678}
11679
11680fn insert_method_dispatch_edges_chunked(
11681    tx: &Transaction<'_>,
11682    project_root: &Path,
11683    chunk_size: usize,
11684) -> Result<usize> {
11685    let total_files = query_count(
11686        tx,
11687        "SELECT COUNT(*) FROM (SELECT DISTINCT caller_file FROM refs)",
11688    )? as usize;
11689    let mut completed_files = 0usize;
11690    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11691    let mut inserted = 0usize;
11692    let mut after_file = String::new();
11693    loop {
11694        let caller_files = {
11695            let mut statement = tx.prepare(
11696                "SELECT DISTINCT caller_file
11697                 FROM refs
11698                 WHERE caller_file > ?1
11699                 ORDER BY caller_file
11700                 LIMIT ?2",
11701            )?;
11702            let rows = statement
11703                .query_map(params![after_file, chunk_size.max(1) as i64], |row| {
11704                    row.get::<_, String>(0)
11705                })?;
11706            rows.collect::<std::result::Result<BTreeSet<_>, _>>()?
11707        };
11708        let Some(last_file) = caller_files.last().cloned() else {
11709            break;
11710        };
11711        inserted += insert_method_dispatch_edges(tx, project_root, Some(&caller_files))?;
11712        after_file = last_file;
11713        completed_files = completed_files
11714            .saturating_add(caller_files.len())
11715            .min(total_files);
11716        ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11717    }
11718    ensure_cold_build_current("method-dispatch", completed_files, total_files)?;
11719    Ok(inserted)
11720}
11721
11722fn insert_method_dispatch_edge(
11723    tx: &Transaction<'_>,
11724    reference: &NameMatchRef,
11725    candidate: &NameMatchCandidate,
11726    provenance: &str,
11727) -> Result<()> {
11728    tx.execute(
11729        "INSERT OR REPLACE INTO edges(
11730            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
11731            kind, line, provenance
11732        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
11733        params![
11734            ref_id(&[&reference.ref_id, provenance, "edge"]),
11735            &reference.ref_id,
11736            &reference.caller_node,
11737            &candidate.node_id,
11738            &candidate.file_path,
11739            &candidate.scoped_name,
11740            reference.line as i64,
11741            provenance,
11742        ],
11743    )?;
11744    Ok(())
11745}
11746
11747fn delete_method_dispatch_edges_for_callers(
11748    tx: &Transaction<'_>,
11749    caller_files: &BTreeSet<String>,
11750) -> Result<()> {
11751    if caller_files.is_empty() {
11752        return Ok(());
11753    }
11754
11755    let mut stmt = tx.prepare(
11756        "DELETE FROM edges
11757         WHERE provenance IN (?1, ?2)
11758           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
11759    )?;
11760    for caller_file in caller_files {
11761        stmt.execute(params![
11762            PROVENANCE_NAME_MATCH,
11763            PROVENANCE_TYPE_MATCH,
11764            caller_file
11765        ])?;
11766    }
11767    Ok(())
11768}
11769
11770fn load_name_match_refs(
11771    tx: &Transaction<'_>,
11772    caller_files: Option<&BTreeSet<String>>,
11773) -> Result<Vec<NameMatchRef>> {
11774    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
11775                           n.signature, r.short_name, r.full_ref, r.line, f.lang
11776                    FROM refs r
11777                    JOIN files f ON f.path = r.caller_file
11778                    JOIN nodes n ON n.id = r.caller_node
11779                    WHERE r.kind = 'call'
11780                      AND r.status = 'unresolved'
11781                      AND r.caller_node IS NOT NULL
11782                      AND r.full_ref IS NOT NULL
11783                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
11784                      AND NOT EXISTS (
11785                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
11786                      )";
11787    let mut references = Vec::new();
11788
11789    if let Some(caller_files) = caller_files {
11790        if caller_files.is_empty() {
11791            return Ok(references);
11792        }
11793        let sql = format!(
11794            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
11795        );
11796        let mut stmt = tx.prepare(&sql)?;
11797        for caller_file in caller_files {
11798            let rows = stmt.query_map(params![caller_file], |row| {
11799                Ok((
11800                    row.get::<_, String>(0)?,
11801                    row.get::<_, Option<String>>(1)?,
11802                    row.get::<_, String>(2)?,
11803                    row.get::<_, String>(3)?,
11804                    row.get::<_, Option<String>>(4)?,
11805                    row.get::<_, Option<String>>(5)?,
11806                    row.get::<_, Option<String>>(6)?,
11807                    row.get::<_, i64>(7)?,
11808                    row.get::<_, String>(8)?,
11809                ))
11810            })?;
11811            for row in rows {
11812                let (
11813                    ref_id,
11814                    caller_node,
11815                    caller_file,
11816                    caller_symbol,
11817                    caller_signature,
11818                    short_name,
11819                    full_ref,
11820                    line,
11821                    lang,
11822                ) = row?;
11823                if let Some(reference) = name_match_ref_from_parts(
11824                    ref_id,
11825                    caller_node,
11826                    caller_file,
11827                    caller_symbol,
11828                    caller_signature,
11829                    short_name,
11830                    full_ref,
11831                    line,
11832                    lang,
11833                ) {
11834                    references.push(reference);
11835                }
11836            }
11837        }
11838        return Ok(references);
11839    }
11840
11841    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
11842    let mut stmt = tx.prepare(&sql)?;
11843    let rows = stmt.query_map([], |row| {
11844        Ok((
11845            row.get::<_, String>(0)?,
11846            row.get::<_, Option<String>>(1)?,
11847            row.get::<_, String>(2)?,
11848            row.get::<_, String>(3)?,
11849            row.get::<_, Option<String>>(4)?,
11850            row.get::<_, Option<String>>(5)?,
11851            row.get::<_, Option<String>>(6)?,
11852            row.get::<_, i64>(7)?,
11853            row.get::<_, String>(8)?,
11854        ))
11855    })?;
11856    for row in rows {
11857        let (
11858            ref_id,
11859            caller_node,
11860            caller_file,
11861            caller_symbol,
11862            caller_signature,
11863            short_name,
11864            full_ref,
11865            line,
11866            lang,
11867        ) = row?;
11868        if let Some(reference) = name_match_ref_from_parts(
11869            ref_id,
11870            caller_node,
11871            caller_file,
11872            caller_symbol,
11873            caller_signature,
11874            short_name,
11875            full_ref,
11876            line,
11877            lang,
11878        ) {
11879            references.push(reference);
11880        }
11881    }
11882    Ok(references)
11883}
11884
11885#[allow(clippy::too_many_arguments)]
11886fn name_match_ref_from_parts(
11887    ref_id: String,
11888    caller_node: Option<String>,
11889    caller_file: String,
11890    caller_symbol: String,
11891    caller_signature: Option<String>,
11892    short_name: Option<String>,
11893    full_ref: Option<String>,
11894    line: i64,
11895    lang: String,
11896) -> Option<NameMatchRef> {
11897    let caller_node = caller_node?;
11898    let full_ref = full_ref?;
11899    let (receiver_expression, receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
11900    let method_name = if member.is_empty() {
11901        short_name.as_deref()?.to_string()
11902    } else {
11903        member
11904    };
11905    Some(NameMatchRef {
11906        ref_id,
11907        caller_node,
11908        caller_file,
11909        caller_symbol,
11910        caller_signature,
11911        receiver_expression,
11912        receiver,
11913        method_name,
11914        colon_dispatch,
11915        line: line.max(0) as u32,
11916        lang,
11917    })
11918}
11919
11920fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, String, bool)> {
11921    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
11922    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
11923    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
11924    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
11925        .into_iter()
11926        .flatten()
11927        .max_by_key(|(index, _, _)| *index)?;
11928    if delimiter == 0 {
11929        return None;
11930    }
11931    let member_start = delimiter + delimiter_len;
11932    if member_start >= full_ref.len() {
11933        return None;
11934    }
11935    let receiver_expression = full_ref[..delimiter].trim();
11936    let receiver = last_name_segment(receiver_expression).trim();
11937    let member = &full_ref[member_start..];
11938    if receiver.is_empty() || member.is_empty() {
11939        return None;
11940    }
11941    Some((
11942        receiver_expression.to_string(),
11943        receiver.to_string(),
11944        member.to_string(),
11945        colon_dispatch,
11946    ))
11947}
11948
11949fn last_name_segment(value: &str) -> &str {
11950    value
11951        .rsplit(['.', ':', '/', '\\', '-', '>'])
11952        .find(|segment| !segment.is_empty())
11953        .unwrap_or(value)
11954}
11955
11956fn load_name_match_candidates(
11957    tx: &Transaction<'_>,
11958    method_name: &str,
11959    lang: &str,
11960) -> Result<Vec<NameMatchCandidate>> {
11961    let mut stmt = tx.prepare(
11962        "SELECT n.id, n.file_path, n.scoped_name, n.kind, n.start_line
11963         FROM nodes n JOIN files f ON f.path = n.file_path
11964         WHERE n.name = ?1
11965           AND f.lang = ?2
11966           AND n.kind IN ('method', 'function', 'kernel')
11967         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
11968    )?;
11969    let rows = stmt.query_map(params![method_name, lang], |row| {
11970        Ok(NameMatchCandidate {
11971            node_id: row.get(0)?,
11972            file_path: row.get(1)?,
11973            scoped_name: row.get(2)?,
11974            kind: row.get(3)?,
11975            start_line: (row.get::<_, i64>(4)?.max(0) as u32).saturating_add(1),
11976        })
11977    })?;
11978    rows.collect::<std::result::Result<Vec<_>, _>>()
11979        .map_err(Into::into)
11980}
11981
11982struct ParsedDispatchSource {
11983    source: String,
11984    tree: tree_sitter::Tree,
11985}
11986
11987type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
11988
11989#[derive(Debug, Clone, PartialEq, Eq)]
11990enum ReceiverTypeInference {
11991    Unknown,
11992    Known(String),
11993    RustDirectSelfField {
11994        receiver_type: String,
11995        declaration_file: String,
11996        module_scope: Vec<(usize, usize)>,
11997    },
11998    KnownButUnresolved,
11999}
12000
12001#[cfg(test)]
12002fn infer_receiver_type(
12003    project_root: &Path,
12004    reference: &NameMatchRef,
12005    source_cache: &mut DispatchSourceCache,
12006) -> Option<String> {
12007    match infer_receiver_type_state(project_root, reference, source_cache) {
12008        ReceiverTypeInference::Known(receiver_type)
12009        | ReceiverTypeInference::RustDirectSelfField { receiver_type, .. } => Some(receiver_type),
12010        ReceiverTypeInference::Unknown | ReceiverTypeInference::KnownButUnresolved => None,
12011    }
12012}
12013
12014fn infer_receiver_type_state(
12015    project_root: &Path,
12016    reference: &NameMatchRef,
12017    source_cache: &mut DispatchSourceCache,
12018) -> ReceiverTypeInference {
12019    let known = |receiver_type| ReceiverTypeInference::Known(receiver_type);
12020    match reference.lang.as_str() {
12021        "rust" => infer_rust_receiver_type(project_root, reference, source_cache),
12022        "java" => {
12023            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
12024                .map(known)
12025                .unwrap_or(ReceiverTypeInference::Unknown)
12026        }
12027        "kotlin" => {
12028            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
12029                .map(known)
12030                .unwrap_or(ReceiverTypeInference::Unknown)
12031        }
12032        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache)
12033            .map(known)
12034            .unwrap_or(ReceiverTypeInference::Unknown),
12035        _ => ReceiverTypeInference::Unknown,
12036    }
12037}
12038
12039fn parse_dispatch_source(
12040    project_root: &Path,
12041    caller_file: &str,
12042    lang: LangId,
12043) -> Option<ParsedDispatchSource> {
12044    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
12045    let grammar = crate::parser::grammar_for(lang);
12046    let mut parser = tree_sitter::Parser::new();
12047    parser.set_language(&grammar).ok()?;
12048    let tree = parser.parse(&source, None)?;
12049    Some(ParsedDispatchSource { source, tree })
12050}
12051
12052fn parsed_dispatch_source<'a>(
12053    project_root: &Path,
12054    reference: &NameMatchRef,
12055    lang: LangId,
12056    source_cache: &'a mut DispatchSourceCache,
12057) -> Option<&'a ParsedDispatchSource> {
12058    parsed_dispatch_source_for_file(
12059        project_root,
12060        &reference.caller_file,
12061        &reference.lang,
12062        lang,
12063        source_cache,
12064    )
12065}
12066
12067fn parsed_dispatch_source_for_file<'a>(
12068    project_root: &Path,
12069    file_path: &str,
12070    lang_label: &str,
12071    lang: LangId,
12072    source_cache: &'a mut DispatchSourceCache,
12073) -> Option<&'a ParsedDispatchSource> {
12074    let key = (file_path.to_string(), lang_label.to_string());
12075    source_cache
12076        .entry(key)
12077        .or_insert_with(|| parse_dispatch_source(project_root, file_path, lang))
12078        .as_ref()
12079}
12080
12081fn infer_java_like_receiver_type(
12082    project_root: &Path,
12083    reference: &NameMatchRef,
12084    lang: LangId,
12085    source_cache: &mut DispatchSourceCache,
12086) -> Option<String> {
12087    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12088        return None;
12089    }
12090
12091    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
12092    let root = parsed.tree.root_node();
12093    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
12094
12095    let callable_scope = type_node
12096        .and_then(|node| {
12097            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
12098        })
12099        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
12100
12101    if let Some(callable_scope) = callable_scope {
12102        if let Some(receiver_type) = infer_java_like_local_receiver_type(
12103            callable_scope,
12104            &parsed.source,
12105            &reference.receiver,
12106            reference.line.max(1),
12107            lang,
12108        ) {
12109            return Some(receiver_type);
12110        }
12111    }
12112
12113    type_node.and_then(|node| {
12114        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
12115    })
12116}
12117
12118fn infer_cpp_receiver_type(
12119    project_root: &Path,
12120    reference: &NameMatchRef,
12121    source_cache: &mut DispatchSourceCache,
12122) -> Option<String> {
12123    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
12124        return None;
12125    }
12126
12127    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
12128    let root = parsed.tree.root_node();
12129    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
12130    infer_cpp_receiver_type_from_scope(
12131        scope,
12132        &parsed.source,
12133        &reference.receiver,
12134        reference.line.max(1),
12135    )
12136}
12137
12138fn find_enclosing_java_like_type_node<'tree>(
12139    root: tree_sitter::Node<'tree>,
12140    source: &str,
12141    reference: &NameMatchRef,
12142    lang: LangId,
12143) -> Option<tree_sitter::Node<'tree>> {
12144    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
12145        .and_then(|name| simple_type_name(&name));
12146    let line = reference.line.max(1);
12147    let mut best = None;
12148    let mut stack = vec![root];
12149    while let Some(node) = stack.pop() {
12150        if !node_contains_line(node, line) {
12151            continue;
12152        }
12153        if is_java_like_type_kind(node.kind(), lang) {
12154            let name = declaration_name(node, source);
12155            if expected_type
12156                .as_deref()
12157                .is_none_or(|expected| name == Some(expected))
12158            {
12159                best = tighter_node(best, node);
12160            }
12161        }
12162        push_named_children(node, &mut stack);
12163    }
12164    best
12165}
12166
12167fn find_enclosing_java_like_callable_node<'tree>(
12168    root: tree_sitter::Node<'tree>,
12169    source: &str,
12170    reference: &NameMatchRef,
12171    lang: LangId,
12172) -> Option<tree_sitter::Node<'tree>> {
12173    let expected_name = reference.caller_symbol.rsplit("::").next();
12174    let line = reference.line.max(1);
12175    let mut best = None;
12176    let mut stack = vec![root];
12177    while let Some(node) = stack.pop() {
12178        if !node_contains_line(node, line) {
12179            continue;
12180        }
12181        if is_java_like_callable_kind(node.kind(), lang) {
12182            let name = declaration_name(node, source);
12183            if expected_name.is_none_or(|expected| name == Some(expected)) {
12184                best = tighter_node(best, node);
12185            }
12186        }
12187        push_named_children(node, &mut stack);
12188    }
12189    best
12190}
12191
12192fn find_enclosing_cpp_callable_node<'tree>(
12193    root: tree_sitter::Node<'tree>,
12194    _source: &str,
12195    reference: &NameMatchRef,
12196) -> Option<tree_sitter::Node<'tree>> {
12197    let line = reference.line.max(1);
12198    let mut best = None;
12199    let mut stack = vec![root];
12200    while let Some(node) = stack.pop() {
12201        if !node_contains_line(node, line) {
12202            continue;
12203        }
12204        if node.kind() == "function_definition" {
12205            best = tighter_node(best, node);
12206        }
12207        push_named_children(node, &mut stack);
12208    }
12209    best
12210}
12211
12212fn tighter_node<'tree>(
12213    current: Option<tree_sitter::Node<'tree>>,
12214    candidate: tree_sitter::Node<'tree>,
12215) -> Option<tree_sitter::Node<'tree>> {
12216    match current {
12217        Some(current)
12218            if current.start_byte() > candidate.start_byte()
12219                || (current.start_byte() == candidate.start_byte()
12220                    && current.end_byte() <= candidate.end_byte()) =>
12221        {
12222            Some(current)
12223        }
12224        _ => Some(candidate),
12225    }
12226}
12227
12228fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
12229    let start = node.start_position().row as u32 + 1;
12230    let end = node.end_position().row as u32 + 1;
12231    start <= line && line <= end
12232}
12233
12234fn push_named_children<'tree>(
12235    node: tree_sitter::Node<'tree>,
12236    stack: &mut Vec<tree_sitter::Node<'tree>>,
12237) {
12238    for index in 0..node.named_child_count() {
12239        if let Some(child) = node.named_child(index as u32) {
12240            stack.push(child);
12241        }
12242    }
12243}
12244
12245fn declaration_name<'source>(
12246    node: tree_sitter::Node<'_>,
12247    source: &'source str,
12248) -> Option<&'source str> {
12249    node.child_by_field_name("name")
12250        .map(|name| node_text(name, source))
12251        .or_else(|| {
12252            first_named_child_text(
12253                node,
12254                source,
12255                &["identifier", "type_identifier", "simple_identifier"],
12256            )
12257        })
12258}
12259
12260fn first_named_child_text<'source>(
12261    node: tree_sitter::Node<'_>,
12262    source: &'source str,
12263    kinds: &[&str],
12264) -> Option<&'source str> {
12265    for index in 0..node.named_child_count() {
12266        let child = node.named_child(index as u32)?;
12267        if kinds.contains(&child.kind()) {
12268            return Some(node_text(child, source));
12269        }
12270    }
12271    None
12272}
12273
12274fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
12275    &source[node.byte_range()]
12276}
12277
12278fn infer_java_like_field_receiver_type(
12279    type_node: tree_sitter::Node<'_>,
12280    source: &str,
12281    receiver: &str,
12282    lang: LangId,
12283) -> Option<String> {
12284    let mut stack = Vec::new();
12285    push_named_children(type_node, &mut stack);
12286    while let Some(node) = stack.pop() {
12287        if is_java_like_field_kind(node.kind(), lang) {
12288            if let Some(receiver_type) =
12289                extract_java_like_declared_type(node_text(node, source), receiver, lang)
12290            {
12291                return Some(receiver_type);
12292            }
12293        }
12294        if is_java_like_type_kind(node.kind(), lang)
12295            || is_java_like_callable_kind(node.kind(), lang)
12296        {
12297            continue;
12298        }
12299        push_named_children(node, &mut stack);
12300    }
12301    None
12302}
12303
12304fn infer_java_like_local_receiver_type(
12305    callable_node: tree_sitter::Node<'_>,
12306    source: &str,
12307    receiver: &str,
12308    call_line: u32,
12309    lang: LangId,
12310) -> Option<String> {
12311    let mut best: Option<(u32, String)> = None;
12312    let mut stack = Vec::new();
12313    push_named_children(callable_node, &mut stack);
12314    while let Some(node) = stack.pop() {
12315        let start_line = node.start_position().row as u32 + 1;
12316        if start_line > call_line {
12317            continue;
12318        }
12319        if is_java_like_local_kind(node.kind(), lang) {
12320            if let Some(receiver_type) =
12321                extract_java_like_declared_type(node_text(node, source), receiver, lang)
12322            {
12323                if best
12324                    .as_ref()
12325                    .is_none_or(|(best_line, _)| start_line >= *best_line)
12326                {
12327                    best = Some((start_line, receiver_type));
12328                }
12329            }
12330        }
12331        if is_java_like_type_kind(node.kind(), lang)
12332            || is_java_like_callable_kind(node.kind(), lang)
12333        {
12334            continue;
12335        }
12336        push_named_children(node, &mut stack);
12337    }
12338    best.map(|(_, receiver_type)| receiver_type)
12339}
12340
12341fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
12342    match lang {
12343        LangId::Java => matches!(
12344            kind,
12345            "class_declaration"
12346                | "interface_declaration"
12347                | "enum_declaration"
12348                | "record_declaration"
12349                | "annotation_type_declaration"
12350        ),
12351        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
12352        _ => false,
12353    }
12354}
12355
12356fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
12357    match lang {
12358        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
12359        LangId::Kotlin => kind == "function_declaration",
12360        _ => false,
12361    }
12362}
12363
12364fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
12365    match lang {
12366        LangId::Java => kind == "field_declaration",
12367        LangId::Kotlin => kind == "property_declaration",
12368        _ => false,
12369    }
12370}
12371
12372fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
12373    match lang {
12374        LangId::Java => kind == "local_variable_declaration",
12375        LangId::Kotlin => kind == "property_declaration",
12376        _ => false,
12377    }
12378}
12379
12380fn extract_java_like_declared_type(
12381    declaration: &str,
12382    receiver: &str,
12383    lang: LangId,
12384) -> Option<String> {
12385    match lang {
12386        LangId::Java => extract_java_declared_type(declaration, receiver),
12387        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
12388        _ => None,
12389    }
12390}
12391
12392fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12393    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12394    let after = declaration[receiver_start + receiver.len()..].trim_start();
12395    if after
12396        .chars()
12397        .next()
12398        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
12399    {
12400        return None;
12401    }
12402
12403    let before = declaration[..receiver_start].trim_end();
12404    if before.contains(',') {
12405        return None;
12406    }
12407    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
12408}
12409
12410fn strip_java_declaration_prefixes(mut value: &str) -> &str {
12411    loop {
12412        value = value.trim_start();
12413        if let Some(stripped) = strip_leading_java_annotation(value) {
12414            value = stripped;
12415            continue;
12416        }
12417        if let Some(stripped) = strip_leading_java_modifier(value) {
12418            value = stripped;
12419            continue;
12420        }
12421        return value.trim();
12422    }
12423}
12424
12425fn strip_leading_java_annotation(value: &str) -> Option<&str> {
12426    let value = value.trim_start();
12427    let mut chars = value.char_indices();
12428    let (_, first) = chars.next()?;
12429    if first != '@' {
12430        return None;
12431    }
12432    let mut end = first.len_utf8();
12433    for (index, ch) in chars {
12434        if !(is_code_ident_char(ch) || ch == '.') {
12435            end = index;
12436            break;
12437        }
12438        end = index + ch.len_utf8();
12439    }
12440    let rest = value[end..].trim_start();
12441    if let Some(stripped) = rest.strip_prefix('(') {
12442        let mut depth = 1usize;
12443        for (index, ch) in stripped.char_indices() {
12444            match ch {
12445                '(' => depth += 1,
12446                ')' => {
12447                    depth = depth.saturating_sub(1);
12448                    if depth == 0 {
12449                        return Some(stripped[index + ch.len_utf8()..].trim_start());
12450                    }
12451                }
12452                _ => {}
12453            }
12454        }
12455        return Some("");
12456    }
12457    Some(rest)
12458}
12459
12460fn strip_leading_java_modifier(value: &str) -> Option<&str> {
12461    const MODIFIERS: &[&str] = &[
12462        "public",
12463        "protected",
12464        "private",
12465        "abstract",
12466        "static",
12467        "final",
12468        "transient",
12469        "volatile",
12470        "synchronized",
12471        "native",
12472        "strictfp",
12473    ];
12474    MODIFIERS
12475        .iter()
12476        .find_map(|modifier| strip_leading_word(value, modifier))
12477}
12478
12479fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
12480    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
12481    let before = &declaration[..receiver_start];
12482    if find_identifier_occurrence(before, "val").is_none()
12483        && find_identifier_occurrence(before, "var").is_none()
12484    {
12485        return None;
12486    }
12487
12488    let after = declaration[receiver_start + receiver.len()..].trim_start();
12489    if let Some(type_text) = after.strip_prefix(':') {
12490        return normalize_receiver_type_name(read_type_prefix(type_text));
12491    }
12492    after
12493        .strip_prefix('=')
12494        .and_then(infer_kotlin_constructor_type)
12495}
12496
12497fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
12498    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
12499    if rest.trim_start().starts_with('(') {
12500        normalize_receiver_type_name(head)
12501    } else {
12502        None
12503    }
12504}
12505
12506fn read_type_prefix(value: &str) -> &str {
12507    let mut angle_depth = 0usize;
12508    for (index, ch) in value.char_indices() {
12509        match ch {
12510            '<' => angle_depth += 1,
12511            '>' => angle_depth = angle_depth.saturating_sub(1),
12512            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
12513                return value[..index].trim();
12514            }
12515            _ => {}
12516        }
12517    }
12518    value.trim()
12519}
12520
12521fn infer_cpp_receiver_type_from_scope(
12522    scope: tree_sitter::Node<'_>,
12523    source: &str,
12524    receiver: &str,
12525    call_line: u32,
12526) -> Option<String> {
12527    let lines = source.lines().collect::<Vec<_>>();
12528    if lines.is_empty() {
12529        return None;
12530    }
12531    let scope_start = scope.start_position().row as usize;
12532    let call_index = (call_line as usize)
12533        .saturating_sub(1)
12534        .min(lines.len().saturating_sub(1));
12535    for index in (scope_start..=call_index).rev() {
12536        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
12537            return Some(receiver_type);
12538        }
12539    }
12540    None
12541}
12542
12543fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
12544    for receiver_start in identifier_occurrences(line, receiver) {
12545        let after = line[receiver_start + receiver.len()..].trim_start();
12546        if after
12547            .chars()
12548            .next()
12549            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
12550        {
12551            continue;
12552        }
12553        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
12554        let normalized = normalize_cpp_type_name(type_text)?;
12555        if normalized == "auto" {
12556            if let Some(rhs) = after.strip_prefix('=') {
12557                return infer_cpp_auto_receiver_type(rhs);
12558            }
12559            continue;
12560        }
12561        return Some(normalized);
12562    }
12563    None
12564}
12565
12566fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
12567    let candidate = prefix
12568        .rsplit([';', '{', '}', '('])
12569        .next()
12570        .unwrap_or(prefix)
12571        .trim();
12572    if candidate.is_empty() || candidate.ends_with(',') {
12573        None
12574    } else {
12575        Some(candidate)
12576    }
12577}
12578
12579fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
12580    let without_templates = strip_angle_groups(type_text);
12581    let mut cleaned = String::with_capacity(without_templates.len());
12582    for token in without_templates.split_whitespace() {
12583        if matches!(
12584            token,
12585            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
12586        ) {
12587            continue;
12588        }
12589        if !cleaned.is_empty() {
12590            cleaned.push(' ');
12591        }
12592        cleaned.push_str(token);
12593    }
12594    let token = cleaned
12595        .split_whitespace()
12596        .last()
12597        .unwrap_or(cleaned.trim())
12598        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
12599        .trim_matches(['*', '&']);
12600    let simple = token.rsplit("::").next().unwrap_or(token).trim();
12601    if simple.is_empty() || cpp_non_type_token(simple) {
12602        None
12603    } else {
12604        Some(simple.to_string())
12605    }
12606}
12607
12608fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
12609    let rhs = rhs.trim_start();
12610    if let Some(after_new) = rhs.strip_prefix("new ") {
12611        return infer_cpp_constructor_type(after_new);
12612    }
12613    infer_cpp_make_template_type(rhs)
12614        .or_else(|| infer_cpp_constructor_type(rhs))
12615        .or_else(|| infer_cpp_factory_type(rhs))
12616}
12617
12618fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
12619    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12620    let normalized = normalize_cpp_type_name(head)?;
12621    if !normalized
12622        .chars()
12623        .next()
12624        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12625    {
12626        return None;
12627    }
12628    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
12629        Some(normalized)
12630    } else {
12631        None
12632    }
12633}
12634
12635fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
12636    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12637    if !rest.trim_start().starts_with('(') {
12638        return None;
12639    }
12640    let base = head.split('<').next().unwrap_or(head);
12641    let base_simple = base.rsplit("::").next().unwrap_or(base);
12642    if !matches!(base_simple, "make_unique" | "make_shared") {
12643        return None;
12644    }
12645    first_angle_arg(head).and_then(normalize_cpp_type_name)
12646}
12647
12648fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
12649    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
12650    if !rest.trim_start().starts_with('(') {
12651        return None;
12652    }
12653    let simple = head
12654        .split('<')
12655        .next()
12656        .unwrap_or(head)
12657        .rsplit("::")
12658        .next()
12659        .unwrap_or(head);
12660    for prefix in ["make", "create", "build"] {
12661        if let Some(suffix) = simple.strip_prefix(prefix) {
12662            if suffix
12663                .chars()
12664                .next()
12665                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12666            {
12667                return normalize_cpp_type_name(suffix);
12668            }
12669        }
12670    }
12671    None
12672}
12673
12674#[derive(Debug, Clone, Copy)]
12675enum JavaLikeInvocation {
12676    Kotlin,
12677    Cpp,
12678}
12679
12680fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
12681    let value = value.trim_start();
12682    let mut end = 0usize;
12683    for (index, ch) in value.char_indices() {
12684        let allowed_separator = match flavor {
12685            JavaLikeInvocation::Kotlin => ch == '.',
12686            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
12687        };
12688        if is_code_ident_char(ch) || allowed_separator {
12689            end = index + ch.len_utf8();
12690            continue;
12691        }
12692        break;
12693    }
12694    if end == 0 {
12695        return None;
12696    }
12697    let mut rest = &value[end..];
12698    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
12699        let skipped = skip_balanced_angle(stripped)?;
12700        let rest_start = rest.len() - rest.trim_start().len();
12701        let angle_len = 1 + skipped;
12702        end += rest_start + angle_len;
12703        rest = &value[end..];
12704    }
12705    Some((value[..end].trim(), rest))
12706}
12707
12708fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
12709    let mut depth = 1usize;
12710    for (index, ch) in value_after_open.char_indices() {
12711        match ch {
12712            '<' => depth += 1,
12713            '>' => {
12714                depth = depth.saturating_sub(1);
12715                if depth == 0 {
12716                    return Some(index + ch.len_utf8());
12717                }
12718            }
12719            _ => {}
12720        }
12721    }
12722    None
12723}
12724
12725fn first_angle_arg(value: &str) -> Option<&str> {
12726    let open = value.find('<')?;
12727    let inner_len = skip_balanced_angle(&value[open + 1..])?;
12728    let inner = &value[open + 1..open + inner_len];
12729    split_top_level_commas(inner).into_iter().next()
12730}
12731
12732fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
12733    let without_generics = strip_angle_groups(type_text);
12734    let cleaned = without_generics
12735        .replace("[]", " ")
12736        .replace("...", " ")
12737        .replace(['?', '&', '*'], " ");
12738    let token = cleaned
12739        .split_whitespace()
12740        .last()
12741        .unwrap_or(cleaned.trim())
12742        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
12743    let token = token.rsplit("::").next().unwrap_or(token);
12744    let simple = token.rsplit('.').next().unwrap_or(token).trim();
12745    if simple.is_empty()
12746        || java_like_primitive_type(simple)
12747        || !simple
12748            .chars()
12749            .next()
12750            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
12751    {
12752        None
12753    } else {
12754        Some(simple.to_string())
12755    }
12756}
12757
12758fn simple_type_name(scoped_name: &str) -> Option<String> {
12759    scoped_name
12760        .rsplit("::")
12761        .find(|segment| !segment.is_empty())
12762        .and_then(normalize_receiver_type_name)
12763}
12764
12765fn strip_angle_groups(value: &str) -> String {
12766    let mut output = String::with_capacity(value.len());
12767    let mut depth = 0usize;
12768    for ch in value.chars() {
12769        match ch {
12770            '<' => {
12771                if depth == 0 {
12772                    output.push(' ');
12773                }
12774                depth += 1;
12775            }
12776            '>' => depth = depth.saturating_sub(1),
12777            _ if depth == 0 => output.push(ch),
12778            _ => {}
12779        }
12780    }
12781    output
12782}
12783
12784fn java_like_primitive_type(value: &str) -> bool {
12785    matches!(
12786        value,
12787        "boolean"
12788            | "byte"
12789            | "char"
12790            | "double"
12791            | "float"
12792            | "int"
12793            | "long"
12794            | "short"
12795            | "void"
12796            | "Boolean"
12797            | "Byte"
12798            | "Char"
12799            | "Double"
12800            | "Float"
12801            | "Int"
12802            | "Long"
12803            | "Short"
12804            | "Unit"
12805    )
12806}
12807
12808fn cpp_non_type_token(value: &str) -> bool {
12809    matches!(
12810        value,
12811        "return"
12812            | "if"
12813            | "else"
12814            | "for"
12815            | "while"
12816            | "do"
12817            | "switch"
12818            | "case"
12819            | "default"
12820            | "break"
12821            | "continue"
12822            | "goto"
12823            | "throw"
12824            | "new"
12825            | "delete"
12826            | "co_await"
12827            | "co_yield"
12828            | "co_return"
12829            | "static_cast"
12830            | "const_cast"
12831            | "dynamic_cast"
12832            | "reinterpret_cast"
12833            | "sizeof"
12834            | "alignof"
12835            | "typeid"
12836            | "and"
12837            | "or"
12838            | "not"
12839            | "xor"
12840    )
12841}
12842
12843fn receiver_is_bare_identifier(value: &str) -> bool {
12844    let mut chars = value.chars();
12845    let Some(first) = chars.next() else {
12846        return false;
12847    };
12848    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
12849}
12850
12851fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
12852    identifier_occurrences(value, needle).into_iter().next()
12853}
12854
12855fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
12856    value
12857        .match_indices(needle)
12858        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
12859        .collect()
12860}
12861
12862fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
12863    let before = value[..start].chars().next_back();
12864    let after = value[start + len..].chars().next();
12865    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
12866}
12867
12868fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
12869    let stripped = value.strip_prefix(word)?;
12870    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
12871        Some(stripped.trim_start())
12872    } else {
12873        None
12874    }
12875}
12876
12877fn is_code_ident_char(ch: char) -> bool {
12878    ch == '_' || ch.is_ascii_alphanumeric()
12879}
12880
12881fn infer_rust_receiver_type(
12882    project_root: &Path,
12883    reference: &NameMatchRef,
12884    source_cache: &mut DispatchSourceCache,
12885) -> ReceiverTypeInference {
12886    if matches!(reference.receiver.as_str(), "self" | "Self") {
12887        return enclosing_type_from_scoped_name(&reference.caller_symbol)
12888            .map(ReceiverTypeInference::Known)
12889            .unwrap_or(ReceiverTypeInference::Unknown);
12890    }
12891
12892    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
12893        return ReceiverTypeInference::Known(reference.receiver.clone());
12894    }
12895
12896    if let Some(receiver_type) = reference
12897        .caller_signature
12898        .as_deref()
12899        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
12900    {
12901        return ReceiverTypeInference::Known(receiver_type);
12902    }
12903
12904    infer_rust_direct_self_field_receiver_type(project_root, reference, source_cache)
12905}
12906
12907fn infer_rust_direct_self_field_receiver_type(
12908    project_root: &Path,
12909    reference: &NameMatchRef,
12910    source_cache: &mut DispatchSourceCache,
12911) -> ReceiverTypeInference {
12912    if reference.colon_dispatch {
12913        return ReceiverTypeInference::Unknown;
12914    }
12915    let Some(field_name) = rust_direct_self_field_name(&reference.receiver_expression) else {
12916        return ReceiverTypeInference::Unknown;
12917    };
12918    if field_name != reference.receiver {
12919        return ReceiverTypeInference::Unknown;
12920    }
12921
12922    let Some(impl_type) = enclosing_type_from_scoped_name(&reference.caller_symbol) else {
12923        return ReceiverTypeInference::Unknown;
12924    };
12925    let Some(struct_name) = rust_direct_nominal_type_name(&impl_type) else {
12926        return ReceiverTypeInference::KnownButUnresolved;
12927    };
12928    let Some(parsed) = parsed_dispatch_source(project_root, reference, LangId::Rust, source_cache)
12929    else {
12930        return ReceiverTypeInference::Unknown;
12931    };
12932    let Some(impl_node) =
12933        find_enclosing_rust_impl_node(parsed.tree.root_node(), reference.line.max(1))
12934    else {
12935        return ReceiverTypeInference::Unknown;
12936    };
12937    if impl_node.child_by_field_name("trait").is_some()
12938        || impl_node.child_by_field_name("type_parameters").is_some()
12939    {
12940        return ReceiverTypeInference::KnownButUnresolved;
12941    }
12942    let Some(impl_target) = impl_node.child_by_field_name("type") else {
12943        return ReceiverTypeInference::KnownButUnresolved;
12944    };
12945    if impl_target.kind() != "type_identifier"
12946        || node_text(impl_target, &parsed.source) != impl_type
12947    {
12948        return ReceiverTypeInference::KnownButUnresolved;
12949    }
12950
12951    let module_scope = rust_module_scope(impl_node);
12952    let Some(struct_node) = find_unique_rust_struct(
12953        parsed.tree.root_node(),
12954        &parsed.source,
12955        struct_name,
12956        &module_scope,
12957    ) else {
12958        return ReceiverTypeInference::KnownButUnresolved;
12959    };
12960    let Some(field_type) = rust_struct_field_type_node(struct_node, &parsed.source, field_name)
12961    else {
12962        return ReceiverTypeInference::KnownButUnresolved;
12963    };
12964    if field_type.kind() != "type_identifier" {
12965        return ReceiverTypeInference::KnownButUnresolved;
12966    }
12967    let field_type_name = node_text(field_type, &parsed.source);
12968    if find_unique_rust_struct(
12969        parsed.tree.root_node(),
12970        &parsed.source,
12971        field_type_name,
12972        &module_scope,
12973    )
12974    .is_none()
12975    {
12976        return ReceiverTypeInference::KnownButUnresolved;
12977    }
12978
12979    ReceiverTypeInference::RustDirectSelfField {
12980        receiver_type: field_type_name.to_string(),
12981        declaration_file: reference.caller_file.clone(),
12982        module_scope,
12983    }
12984}
12985
12986fn rust_direct_self_field_name(receiver_expression: &str) -> Option<&str> {
12987    let (base, field) = receiver_expression.split_once('.')?;
12988    let base = base.trim();
12989    let field = field.trim();
12990    (base == "self" && rust_direct_nominal_type_name(field).is_some()).then_some(field)
12991}
12992
12993fn rust_direct_nominal_type_name(value: &str) -> Option<&str> {
12994    let name = value.rsplit("::").next()?.trim();
12995    (!name.is_empty()
12996        && !name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
12997        && name.chars().all(is_rust_ident_char))
12998    .then_some(name)
12999}
13000
13001fn find_enclosing_rust_impl_node<'tree>(
13002    root: tree_sitter::Node<'tree>,
13003    line: u32,
13004) -> Option<tree_sitter::Node<'tree>> {
13005    let mut best = None;
13006    let mut stack = vec![root];
13007    while let Some(node) = stack.pop() {
13008        if !node_contains_line(node, line) {
13009            continue;
13010        }
13011        if node.kind() == "impl_item" {
13012            best = tighter_node(best, node);
13013        }
13014        push_named_children(node, &mut stack);
13015    }
13016    best
13017}
13018
13019fn rust_module_scope(node: tree_sitter::Node<'_>) -> Vec<(usize, usize)> {
13020    let mut scope = Vec::new();
13021    let mut current = node.parent();
13022    while let Some(parent) = current {
13023        if parent.kind() == "mod_item" {
13024            scope.push((parent.start_byte(), parent.end_byte()));
13025        }
13026        current = parent.parent();
13027    }
13028    scope.reverse();
13029    scope
13030}
13031
13032fn find_unique_rust_struct<'tree>(
13033    root: tree_sitter::Node<'tree>,
13034    source: &str,
13035    expected_name: &str,
13036    module_scope: &[(usize, usize)],
13037) -> Option<tree_sitter::Node<'tree>> {
13038    let mut found = None;
13039    let mut stack = vec![root];
13040    while let Some(node) = stack.pop() {
13041        if node.kind() == "struct_item"
13042            && rust_module_scope(node) == module_scope
13043            && node.child_by_field_name("type_parameters").is_none()
13044            && declaration_name(node, source) == Some(expected_name)
13045        {
13046            if found.is_some() {
13047                return None;
13048            }
13049            found = Some(node);
13050        }
13051        push_named_children(node, &mut stack);
13052    }
13053    found
13054}
13055
13056fn rust_struct_field_type_node<'tree>(
13057    struct_node: tree_sitter::Node<'tree>,
13058    source: &str,
13059    field_name: &str,
13060) -> Option<tree_sitter::Node<'tree>> {
13061    let fields = struct_node.child_by_field_name("body")?;
13062    if fields.kind() != "field_declaration_list" {
13063        return None;
13064    }
13065    for index in 0..fields.named_child_count() {
13066        let field = fields.named_child(index as u32)?;
13067        if field.kind() != "field_declaration"
13068            || declaration_name(field, source) != Some(field_name)
13069        {
13070            continue;
13071        }
13072        return field.child_by_field_name("type");
13073    }
13074    None
13075}
13076
13077fn rust_receiver_looks_type_like(receiver: &str) -> bool {
13078    receiver
13079        .chars()
13080        .next()
13081        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
13082}
13083
13084fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
13085    scoped_name
13086        .rsplit_once("::")
13087        .map(|(enclosing, _)| enclosing)
13088        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
13089        .map(ToString::to_string)
13090}
13091
13092fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
13093    let params = signature_parameter_text(signature)?;
13094    for param in split_top_level_commas(params) {
13095        let Some((pattern, type_text)) = param.split_once(':') else {
13096            continue;
13097        };
13098        let Some(name) = rust_parameter_name(pattern) else {
13099            continue;
13100        };
13101        if name == receiver {
13102            return normalize_rust_receiver_type(type_text);
13103        }
13104    }
13105    None
13106}
13107
13108fn signature_parameter_text(signature: &str) -> Option<&str> {
13109    let open = signature.find('(')?;
13110    let mut depth = 0usize;
13111    for (offset, ch) in signature[open..].char_indices() {
13112        match ch {
13113            '(' => depth += 1,
13114            ')' => {
13115                depth = depth.saturating_sub(1);
13116                if depth == 0 {
13117                    return Some(&signature[open + 1..open + offset]);
13118                }
13119            }
13120            _ => {}
13121        }
13122    }
13123    None
13124}
13125
13126fn split_top_level_commas(value: &str) -> Vec<&str> {
13127    let mut parts = Vec::new();
13128    let mut start = 0usize;
13129    let mut angle_depth = 0usize;
13130    let mut paren_depth = 0usize;
13131    let mut bracket_depth = 0usize;
13132    for (index, ch) in value.char_indices() {
13133        match ch {
13134            '<' => angle_depth += 1,
13135            '>' => angle_depth = angle_depth.saturating_sub(1),
13136            '(' => paren_depth += 1,
13137            ')' => paren_depth = paren_depth.saturating_sub(1),
13138            '[' => bracket_depth += 1,
13139            ']' => bracket_depth = bracket_depth.saturating_sub(1),
13140            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
13141                let part = value[start..index].trim();
13142                if !part.is_empty() {
13143                    parts.push(part);
13144                }
13145                start = index + ch.len_utf8();
13146            }
13147            _ => {}
13148        }
13149    }
13150    let part = value[start..].trim();
13151    if !part.is_empty() {
13152        parts.push(part);
13153    }
13154    parts
13155}
13156
13157fn rust_parameter_name(pattern: &str) -> Option<&str> {
13158    let mut pattern = pattern.trim();
13159    if let Some(stripped) = pattern.strip_prefix("mut ") {
13160        pattern = stripped.trim_start();
13161    }
13162    pattern
13163        .rsplit(|ch: char| !is_rust_ident_char(ch))
13164        .find(|part| !part.is_empty())
13165}
13166
13167fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
13168    let mut ty = strip_leading_rust_type_modifiers(type_text);
13169    let owned_inner;
13170    if let Some(inner) = single_outer_generic_arg(ty) {
13171        owned_inner = inner.trim().to_string();
13172        ty = strip_leading_rust_type_modifiers(&owned_inner);
13173    }
13174    rust_base_type_ident(ty)
13175}
13176
13177fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
13178    loop {
13179        ty = ty.trim_start();
13180        if let Some(stripped) = ty.strip_prefix('&') {
13181            ty = stripped.trim_start();
13182            if let Some(stripped) = strip_leading_lifetime(ty) {
13183                ty = stripped.trim_start();
13184            }
13185            if let Some(stripped) = ty.strip_prefix("mut ") {
13186                ty = stripped.trim_start();
13187            }
13188            continue;
13189        }
13190        if let Some(stripped) = ty.strip_prefix("mut ") {
13191            ty = stripped.trim_start();
13192            continue;
13193        }
13194        if let Some(stripped) = ty.strip_prefix("dyn ") {
13195            ty = stripped.trim_start();
13196            continue;
13197        }
13198        if let Some(stripped) = ty.strip_prefix("impl ") {
13199            ty = stripped.trim_start();
13200            continue;
13201        }
13202        break ty.trim();
13203    }
13204}
13205
13206fn strip_leading_lifetime(value: &str) -> Option<&str> {
13207    let mut chars = value.char_indices();
13208    let (_, first) = chars.next()?;
13209    if first != '\'' {
13210        return None;
13211    }
13212    for (index, ch) in chars {
13213        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
13214            return Some(&value[index..]);
13215        }
13216    }
13217    Some("")
13218}
13219
13220fn single_outer_generic_arg(ty: &str) -> Option<&str> {
13221    let ty = ty.trim();
13222    let open = ty.find('<')?;
13223    let mut depth = 0usize;
13224    let mut close = None;
13225    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
13226        match ch {
13227            '<' => depth += 1,
13228            '>' => {
13229                depth = depth.saturating_sub(1);
13230                if depth == 0 {
13231                    close = Some(index);
13232                    break;
13233                }
13234            }
13235            _ => {}
13236        }
13237    }
13238    let close = close?;
13239    if !ty[close + 1..].trim().is_empty() {
13240        return None;
13241    }
13242    let inner = &ty[open + 1..close];
13243    let args = split_top_level_commas(inner);
13244    match args.as_slice() {
13245        [arg] => Some(*arg),
13246        _ => None,
13247    }
13248}
13249
13250fn rust_base_type_ident(ty: &str) -> Option<String> {
13251    let ty = ty.trim();
13252    let head = ty
13253        .split([' ', '+', '='])
13254        .find(|part| !part.is_empty())
13255        .unwrap_or(ty);
13256    let head = head.split('<').next().unwrap_or(head).trim();
13257    let ident = head
13258        .rsplit("::")
13259        .next()
13260        .unwrap_or(head)
13261        .trim_matches(|ch: char| !is_rust_ident_char(ch));
13262    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
13263        None
13264    } else {
13265        Some(ident.to_string())
13266    }
13267}
13268
13269fn is_rust_ident_char(ch: char) -> bool {
13270    ch == '_' || ch.is_ascii_alphanumeric()
13271}
13272
13273fn select_rust_direct_self_field_candidate(
13274    project_root: &Path,
13275    reference: &NameMatchRef,
13276    candidates: &[NameMatchCandidate],
13277    receiver_type: &str,
13278    declaration_file: &str,
13279    declaration_scope: &[(usize, usize)],
13280    source_cache: &mut DispatchSourceCache,
13281) -> Option<NameMatchCandidate> {
13282    let eligible = candidates
13283        .iter()
13284        .filter(|candidate| candidate.node_id != reference.caller_node)
13285        .filter(|candidate| {
13286            type_candidate_matches(candidate, receiver_type, &reference.method_name)
13287        })
13288        .filter(|candidate| {
13289            rust_direct_self_field_candidate_matches_scope(
13290                project_root,
13291                candidate,
13292                receiver_type,
13293                declaration_file,
13294                declaration_scope,
13295                source_cache,
13296            )
13297        })
13298        .collect::<Vec<_>>();
13299    match eligible.as_slice() {
13300        [candidate] => Some((**candidate).clone()),
13301        _ => None,
13302    }
13303}
13304
13305fn rust_direct_self_field_candidate_matches_scope(
13306    project_root: &Path,
13307    candidate: &NameMatchCandidate,
13308    receiver_type: &str,
13309    declaration_file: &str,
13310    declaration_scope: &[(usize, usize)],
13311    source_cache: &mut DispatchSourceCache,
13312) -> bool {
13313    if candidate.file_path != declaration_file {
13314        return false;
13315    }
13316    let Some(parsed) = parsed_dispatch_source_for_file(
13317        project_root,
13318        &candidate.file_path,
13319        "rust",
13320        LangId::Rust,
13321        source_cache,
13322    ) else {
13323        return false;
13324    };
13325    let Some(impl_node) =
13326        find_enclosing_rust_impl_node(parsed.tree.root_node(), candidate.start_line)
13327    else {
13328        return false;
13329    };
13330    if impl_node.child_by_field_name("trait").is_some()
13331        || impl_node.child_by_field_name("type_parameters").is_some()
13332    {
13333        return false;
13334    }
13335    let Some(impl_target) = impl_node.child_by_field_name("type") else {
13336        return false;
13337    };
13338    impl_target.kind() == "type_identifier"
13339        && node_text(impl_target, &parsed.source) == receiver_type
13340        && rust_module_scope(impl_node) == declaration_scope
13341}
13342
13343fn select_type_match_candidate(
13344    reference: &NameMatchRef,
13345    candidates: &[NameMatchCandidate],
13346    receiver_type: &str,
13347) -> Option<NameMatchCandidate> {
13348    let candidates = candidates
13349        .iter()
13350        .filter(|candidate| candidate.node_id != reference.caller_node)
13351        .filter(|candidate| {
13352            type_candidate_matches(candidate, receiver_type, &reference.method_name)
13353        })
13354        .collect::<Vec<_>>();
13355    match candidates.as_slice() {
13356        [candidate] => Some((**candidate).clone()),
13357        _ => None,
13358    }
13359}
13360
13361fn type_candidate_matches(
13362    candidate: &NameMatchCandidate,
13363    receiver_type: &str,
13364    method_name: &str,
13365) -> bool {
13366    let normalized_type = receiver_type.replace('.', "::");
13367    let suffix = format!("{normalized_type}::{method_name}");
13368    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
13369}
13370
13371fn select_name_match_candidate(
13372    reference: &NameMatchRef,
13373    candidates: &[NameMatchCandidate],
13374) -> Option<NameMatchCandidate> {
13375    let candidates = candidates
13376        .iter()
13377        .filter(|candidate| candidate.node_id != reference.caller_node)
13378        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
13379        .collect::<Vec<_>>();
13380    match candidates.as_slice() {
13381        [] => None,
13382        [candidate] => Some((**candidate).clone()),
13383        _ => select_scored_name_match_candidate(reference, &candidates),
13384    }
13385}
13386
13387fn candidate_allowed_for_reference(
13388    reference: &NameMatchRef,
13389    candidate: &NameMatchCandidate,
13390) -> bool {
13391    if !reference.colon_dispatch {
13392        return true;
13393    }
13394
13395    candidate.kind == "method"
13396        && candidate
13397            .scoped_name
13398            .split("::")
13399            .any(|segment| segment == reference.receiver)
13400}
13401
13402fn select_scored_name_match_candidate(
13403    reference: &NameMatchRef,
13404    candidates: &[&NameMatchCandidate],
13405) -> Option<NameMatchCandidate> {
13406    let receiver_words = split_camel_case(&reference.receiver);
13407    if receiver_words.is_empty() {
13408        return None;
13409    }
13410
13411    let mut best: Option<(&NameMatchCandidate, f64)> = None;
13412    let mut tied_best = false;
13413    for candidate in candidates {
13414        let candidate_words = split_camel_case(&candidate.scoped_name);
13415        let overlap = receiver_words
13416            .iter()
13417            .filter(|receiver_word| {
13418                candidate_words
13419                    .iter()
13420                    .any(|candidate_word| candidate_word == *receiver_word)
13421            })
13422            .count() as f64;
13423        let score =
13424            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
13425        match best {
13426            None => {
13427                best = Some((*candidate, score));
13428                tied_best = false;
13429            }
13430            Some((_, best_score)) if score > best_score => {
13431                best = Some((*candidate, score));
13432                tied_best = false;
13433            }
13434            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
13435                tied_best = true;
13436            }
13437            _ => {}
13438        }
13439    }
13440
13441    let (candidate, score) = best?;
13442    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
13443        Some(candidate.clone())
13444    } else {
13445        None
13446    }
13447}
13448
13449fn method_name_match_denylisted(method_name: &str) -> bool {
13450    matches!(
13451        method_name,
13452        "and_then"
13453            | "as_bytes"
13454            | "as_deref"
13455            | "as_mut"
13456            | "as_ref"
13457            | "as_str"
13458            | "borrow"
13459            | "borrow_mut"
13460            | "clear"
13461            | "clone"
13462            | "collect"
13463            | "contains"
13464            | "contains_key"
13465            | "count"
13466            | "dedup"
13467            | "default"
13468            | "drain"
13469            | "ends_with"
13470            | "entry"
13471            | "err"
13472            | "expect"
13473            | "extend"
13474            | "filter"
13475            | "filter_map"
13476            | "find"
13477            | "from"
13478            | "get"
13479            | "get_mut"
13480            | "insert"
13481            | "into"
13482            | "into_iter"
13483            | "is_empty"
13484            | "is_err"
13485            | "is_none"
13486            | "is_ok"
13487            | "is_some"
13488            | "iter"
13489            | "iter_mut"
13490            | "join"
13491            | "len"
13492            | "lock"
13493            | "map"
13494            | "map_err"
13495            | "max"
13496            | "min"
13497            | "new"
13498            | "next"
13499            | "ok"
13500            | "or_default"
13501            | "or_else"
13502            | "or_insert"
13503            | "or_insert_with"
13504            | "parse"
13505            | "pop"
13506            | "position"
13507            | "push"
13508            | "read"
13509            | "recv"
13510            | "remove"
13511            | "replace"
13512            | "retain"
13513            | "send"
13514            | "sort"
13515            | "sort_by"
13516            | "split"
13517            | "starts_with"
13518            | "sum"
13519            | "take"
13520            | "to_owned"
13521            | "to_string"
13522            | "trim"
13523            | "try_from"
13524            | "try_into"
13525            | "unwrap"
13526            | "unwrap_or"
13527            | "unwrap_or_default"
13528            | "unwrap_or_else"
13529            | "with_capacity"
13530            | "write"
13531    )
13532}
13533
13534fn split_camel_case(value: &str) -> Vec<String> {
13535    let chars = value.chars().collect::<Vec<_>>();
13536    let mut normalized = String::with_capacity(value.len() + 8);
13537    for (index, ch) in chars.iter().enumerate() {
13538        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
13539        let next = chars.get(index + 1);
13540        let is_separator = ch.is_whitespace()
13541            || matches!(
13542                ch,
13543                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
13544            );
13545        if is_separator {
13546            normalized.push(' ');
13547            continue;
13548        }
13549        let camel_boundary = previous.is_some_and(|prev| {
13550            (prev.is_lowercase() && ch.is_uppercase())
13551                || (prev.is_ascii_digit() && ch.is_alphabetic())
13552                || (prev.is_uppercase()
13553                    && ch.is_uppercase()
13554                    && next.is_some_and(|next| next.is_lowercase()))
13555        });
13556        if camel_boundary {
13557            normalized.push(' ');
13558        }
13559        normalized.push(*ch);
13560    }
13561
13562    normalized
13563        .split_whitespace()
13564        .filter(|word| word.len() > 1)
13565        .map(|word| word.to_ascii_lowercase())
13566        .collect()
13567}
13568
13569fn compute_path_proximity(left: &str, right: &str) -> f64 {
13570    let left_dirs = left
13571        .rsplit_once('/')
13572        .map(|(dir, _)| dir)
13573        .unwrap_or_default()
13574        .split('/')
13575        .filter(|part| !part.is_empty());
13576    let right_dirs = right
13577        .rsplit_once('/')
13578        .map(|(dir, _)| dir)
13579        .unwrap_or_default()
13580        .split('/')
13581        .filter(|part| !part.is_empty());
13582
13583    let shared = left_dirs
13584        .zip(right_dirs)
13585        .take_while(|(left, right)| left == right)
13586        .count();
13587    ((shared as f64) * 0.05).min(0.5)
13588}
13589
13590fn mark_backend_state(
13591    tx: &Transaction<'_>,
13592    project_root: &Path,
13593    rel_path: &str,
13594    content_hash: Option<&blake3::Hash>,
13595    status: &str,
13596) -> Result<()> {
13597    clear_backend_state_for_file(tx, project_root, rel_path)?;
13598    let hash = content_hash
13599        .map(|hash| hash_to_hex(*hash))
13600        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
13601    tx.execute(
13602        "INSERT OR REPLACE INTO backend_file_state(
13603            backend, workspace_root, file_path, content_hash, status, updated_at
13604        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
13605        params![
13606            BACKEND_TREESITTER,
13607            project_root.display().to_string(),
13608            rel_path,
13609            hash,
13610            status,
13611            unix_seconds_now(),
13612        ],
13613    )?;
13614    Ok(())
13615}
13616
13617fn clear_backend_state_for_file(
13618    tx: &Transaction<'_>,
13619    project_root: &Path,
13620    rel_path: &str,
13621) -> Result<()> {
13622    tx.execute(
13623        "DELETE FROM backend_file_state
13624         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
13625        params![
13626            BACKEND_TREESITTER,
13627            project_root.display().to_string(),
13628            rel_path
13629        ],
13630    )?;
13631    Ok(())
13632}
13633
13634/// Mark a file whose graph bytes were just confirmed current as fresh.
13635///
13636/// `refresh_files` skips extracts for HotFresh inputs, so without this write a
13637/// leftover `status='stale'` row from a failed refresh would keep blocking
13638/// dead-code projection even though the graph still matches disk.
13639fn clear_stale_backend_status_for_file(
13640    tx: &Transaction<'_>,
13641    project_root: &Path,
13642    rel_path: &str,
13643) -> Result<()> {
13644    tx.execute(
13645        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
13646         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3 AND status = 'stale'",
13647        params![
13648            BACKEND_TREESITTER,
13649            project_root.display().to_string(),
13650            rel_path,
13651            unix_seconds_now(),
13652        ],
13653    )?;
13654    Ok(())
13655}
13656
13657fn load_file_row(conn: &Connection, rel_path: &str) -> Result<Option<FileRow>> {
13658    conn.query_row(
13659        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
13660        params![rel_path],
13661        |row| {
13662            let hash_text: String = row.get(1)?;
13663            Ok(FileRow {
13664                surface_fingerprint: row.get(0)?,
13665                freshness: FileFreshness {
13666                    content_hash: hash_from_hex(&hash_text)
13667                        .unwrap_or_else(cache_freshness::zero_hash),
13668                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
13669                    size: row.get::<_, i64>(3)? as u64,
13670                },
13671            })
13672        },
13673    )
13674    .optional()
13675    .map_err(CallGraphStoreError::from)
13676}
13677
13678fn stored_node_ids_match_extract(
13679    tx: &Transaction<'_>,
13680    rel_path: &str,
13681    extract: &FileExtract,
13682) -> Result<bool> {
13683    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
13684    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
13685    let mut stored = BTreeSet::new();
13686    for row in rows {
13687        stored.insert(row?);
13688    }
13689    let extracted = extract
13690        .nodes
13691        .iter()
13692        .map(|node| node.id.clone())
13693        .collect::<BTreeSet<_>>();
13694    Ok(stored == extracted)
13695}
13696
13697/// Compare every persisted graph row that comes from this file before rewriting it.
13698/// Ranges and reference byte offsets are part of the key because queries expose
13699/// source locations; equal names and edges are not enough after a body shift.
13700fn stored_extract_matches(
13701    tx: &Transaction<'_>,
13702    rel_path: &str,
13703    extract: &FileExtract,
13704    index: &ProjectIndex<'_>,
13705) -> Result<bool> {
13706    let stored_file = tx
13707        .query_row(
13708            "SELECT lang, surface_fingerprint FROM files WHERE path = ?1",
13709            params![rel_path],
13710            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
13711        )
13712        .optional()?;
13713    if stored_file
13714        != Some((
13715            lang_label(extract.lang).to_string(),
13716            extract.surface_fingerprint.clone(),
13717        ))
13718    {
13719        return Ok(false);
13720    }
13721
13722    let mut stored_nodes_stmt = tx.prepare(
13723        "SELECT id, file_path, name, scoped_name, kind, start_line, start_col,
13724                end_line, end_col, range_ordinal, signature, exported,
13725                is_default_export, is_type_like, is_callgraph_entry_point, provenance
13726         FROM nodes WHERE file_path = ?1",
13727    )?;
13728    let stored_nodes = stored_nodes_stmt
13729        .query_map(params![rel_path], |row| {
13730            Ok(serde_json::json!([
13731                row.get::<_, String>(0)?,
13732                row.get::<_, String>(1)?,
13733                row.get::<_, String>(2)?,
13734                row.get::<_, String>(3)?,
13735                row.get::<_, String>(4)?,
13736                row.get::<_, i64>(5)?,
13737                row.get::<_, i64>(6)?,
13738                row.get::<_, i64>(7)?,
13739                row.get::<_, i64>(8)?,
13740                row.get::<_, i64>(9)?,
13741                row.get::<_, Option<String>>(10)?,
13742                row.get::<_, i64>(11)?,
13743                row.get::<_, i64>(12)?,
13744                row.get::<_, i64>(13)?,
13745                row.get::<_, i64>(14)?,
13746                row.get::<_, String>(15)?,
13747            ])
13748            .to_string())
13749        })?
13750        .collect::<rusqlite::Result<Vec<_>>>()?;
13751    let expected_nodes = extract
13752        .nodes
13753        .iter()
13754        .map(|node| {
13755            serde_json::json!([
13756                node.id,
13757                node.file_path,
13758                node.name,
13759                node.scoped_name,
13760                node.kind,
13761                node.range.start_line,
13762                node.range.start_col,
13763                node.range.end_line,
13764                node.range.end_col,
13765                node.range_ordinal,
13766                node.signature,
13767                bool_int(node.exported),
13768                bool_int(node.is_default_export),
13769                bool_int(node.is_type_like),
13770                bool_int(node.is_callgraph_entry_point),
13771                PROVENANCE_TREESITTER,
13772            ])
13773            .to_string()
13774        })
13775        .collect::<Vec<_>>();
13776    let mut stored_nodes = stored_nodes;
13777    let mut expected_nodes = expected_nodes;
13778    stored_nodes.sort();
13779    expected_nodes.sort();
13780    if stored_nodes != expected_nodes {
13781        return Ok(false);
13782    }
13783
13784    let resolved_refs = extract
13785        .raw_refs
13786        .iter()
13787        .cloned()
13788        .map(|raw| resolve_ref(raw, index))
13789        .collect::<Result<Vec<_>>>()?;
13790    let mut stored_refs_stmt = tx.prepare(
13791        "SELECT ref_id, caller_node, caller_file, kind, short_name, full_ref,
13792                module_path, import_kind, local_name, requested_name, namespace_alias,
13793                wildcard, line, byte_start, byte_end, status, target_node,
13794                target_file, target_symbol, provenance
13795         FROM refs WHERE caller_file = ?1",
13796    )?;
13797    let stored_refs = stored_refs_stmt
13798        .query_map(params![rel_path], |row| {
13799            Ok(serde_json::json!([
13800                row.get::<_, String>(0)?,
13801                row.get::<_, Option<String>>(1)?,
13802                row.get::<_, String>(2)?,
13803                row.get::<_, String>(3)?,
13804                row.get::<_, Option<String>>(4)?,
13805                row.get::<_, Option<String>>(5)?,
13806                row.get::<_, Option<String>>(6)?,
13807                row.get::<_, Option<String>>(7)?,
13808                row.get::<_, Option<String>>(8)?,
13809                row.get::<_, Option<String>>(9)?,
13810                row.get::<_, Option<String>>(10)?,
13811                row.get::<_, i64>(11)?,
13812                row.get::<_, i64>(12)?,
13813                row.get::<_, i64>(13)?,
13814                row.get::<_, i64>(14)?,
13815                row.get::<_, String>(15)?,
13816                row.get::<_, Option<String>>(16)?,
13817                row.get::<_, Option<String>>(17)?,
13818                row.get::<_, Option<String>>(18)?,
13819                row.get::<_, String>(19)?,
13820            ])
13821            .to_string())
13822        })?
13823        .collect::<rusqlite::Result<Vec<_>>>()?;
13824    let expected_refs = resolved_refs
13825        .iter()
13826        .map(|resolved| {
13827            let raw = &resolved.raw;
13828            serde_json::json!([
13829                raw.ref_id,
13830                raw.caller_node,
13831                raw.caller_file,
13832                raw.kind,
13833                raw.short_name,
13834                raw.full_ref,
13835                raw.module_path,
13836                raw.import_kind,
13837                raw.local_name,
13838                raw.requested_name,
13839                raw.namespace_alias,
13840                bool_int(raw.wildcard),
13841                raw.line,
13842                raw.byte_start,
13843                raw.byte_end,
13844                resolved.status,
13845                resolved.target_node,
13846                resolved.target_file,
13847                resolved.target_symbol,
13848                ref_provenance(raw),
13849            ])
13850            .to_string()
13851        })
13852        .collect::<Vec<_>>();
13853    let mut stored_refs = stored_refs;
13854    let mut expected_refs = expected_refs;
13855    stored_refs.sort();
13856    expected_refs.sort();
13857    if stored_refs != expected_refs {
13858        return Ok(false);
13859    }
13860
13861    let mut stored_edges_stmt = tx.prepare(
13862        "SELECT e.edge_id, e.ref_id, e.source_node, e.target_node,
13863                e.target_file, e.target_symbol, e.kind, e.line, e.provenance
13864         FROM edges e JOIN refs r ON r.ref_id = e.ref_id
13865         WHERE r.caller_file = ?1 AND e.provenance = ?2",
13866    )?;
13867    let stored_edges = stored_edges_stmt
13868        .query_map(params![rel_path, PROVENANCE_TREESITTER], |row| {
13869            Ok(serde_json::json!([
13870                row.get::<_, String>(0)?,
13871                row.get::<_, String>(1)?,
13872                row.get::<_, String>(2)?,
13873                row.get::<_, Option<String>>(3)?,
13874                row.get::<_, String>(4)?,
13875                row.get::<_, String>(5)?,
13876                row.get::<_, String>(6)?,
13877                row.get::<_, i64>(7)?,
13878                row.get::<_, String>(8)?,
13879            ])
13880            .to_string())
13881        })?
13882        .collect::<rusqlite::Result<Vec<_>>>()?;
13883    let expected_edges = resolved_refs
13884        .iter()
13885        .filter_map(|resolved| {
13886            resolved.edge.as_ref().map(|edge| {
13887                serde_json::json!([
13888                    edge.edge_id,
13889                    resolved.raw.ref_id,
13890                    edge.source_node,
13891                    edge.target_node,
13892                    edge.target_file,
13893                    edge.target_symbol,
13894                    edge.kind,
13895                    edge.line,
13896                    ref_provenance(&resolved.raw),
13897                ])
13898                .to_string()
13899            })
13900        })
13901        .collect::<Vec<_>>();
13902    let mut stored_edges = stored_edges;
13903    let mut expected_edges = expected_edges;
13904    stored_edges.sort();
13905    expected_edges.sort();
13906    if stored_edges != expected_edges {
13907        return Ok(false);
13908    }
13909
13910    let mut stored_dependencies_stmt =
13911        tx.prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1")?;
13912    let stored_dependencies = stored_dependencies_stmt
13913        .query_map(params![rel_path], |row| row.get::<_, String>(0))?
13914        .collect::<rusqlite::Result<BTreeSet<_>>>()?;
13915    let expected_dependencies = extract
13916        .raw_refs
13917        .iter()
13918        .flat_map(|raw| raw.dependencies.iter().cloned())
13919        .collect::<BTreeSet<_>>();
13920    if stored_dependencies != expected_dependencies {
13921        return Ok(false);
13922    }
13923
13924    let mut stored_hints_stmt = tx.prepare(
13925        "SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
13926         FROM dispatch_hints WHERE file = ?1",
13927    )?;
13928    let stored_hints = stored_hints_stmt
13929        .query_map(params![rel_path], |row| {
13930            Ok(serde_json::json!([
13931                row.get::<_, String>(0)?,
13932                row.get::<_, String>(1)?,
13933                row.get::<_, String>(2)?,
13934                row.get::<_, String>(3)?,
13935                row.get::<_, i64>(4)?,
13936                row.get::<_, i64>(5)?,
13937                row.get::<_, i64>(6)?,
13938                row.get::<_, String>(7)?,
13939            ])
13940            .to_string())
13941        })?
13942        .collect::<rusqlite::Result<Vec<_>>>()?;
13943    let expected_hints = extract
13944        .dispatch_hints
13945        .iter()
13946        .map(|hint| {
13947            serde_json::json!([
13948                hint.id,
13949                hint.method_name,
13950                hint.caller_node,
13951                hint.file,
13952                hint.line,
13953                hint.byte_start,
13954                hint.byte_end,
13955                PROVENANCE_TREESITTER,
13956            ])
13957            .to_string()
13958        })
13959        .collect::<Vec<_>>();
13960    let mut stored_hints = stored_hints;
13961    let mut expected_hints = expected_hints;
13962    stored_hints.sort();
13963    expected_hints.sort();
13964    Ok(stored_hints == expected_hints)
13965}
13966
13967fn update_file_fresh_metadata(
13968    tx: &Transaction<'_>,
13969    project_root: &Path,
13970    rel_path: &str,
13971    hash: &blake3::Hash,
13972    mtime: SystemTime,
13973    size: u64,
13974) -> Result<()> {
13975    tx.execute(
13976        "UPDATE files SET content_hash = ?2, mtime_ns = ?3, size = ?4, indexed_at = ?5
13977         WHERE path = ?1",
13978        params![
13979            rel_path,
13980            hash_to_hex(*hash),
13981            system_time_to_ns(mtime),
13982            size as i64,
13983            unix_seconds_now()
13984        ],
13985    )?;
13986    tx.execute(
13987        "UPDATE backend_file_state SET content_hash = ?3, status = 'fresh', updated_at = ?5
13988         WHERE backend = ?1 AND file_path = ?2 AND workspace_root = ?4",
13989        params![
13990            BACKEND_TREESITTER,
13991            rel_path,
13992            hash_to_hex(*hash),
13993            project_root.display().to_string(),
13994            unix_seconds_now(),
13995        ],
13996    )?;
13997    Ok(())
13998}
13999
14000#[derive(Debug, Clone, PartialEq, Eq)]
14001struct DependentRefSelection {
14002    ref_id: String,
14003    caller_file: String,
14004}
14005
14006fn ref_ids_depending_on(
14007    conn: &Connection,
14008    project_root: &Path,
14009    rel_path: &str,
14010) -> Result<Vec<DependentRefSelection>> {
14011    let mut stmt = conn.prepare(
14012        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
14013         FROM refs r
14014         WHERE r.caller_file IN (
14015             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
14016         )
14017            OR r.target_file = ?1
14018         ORDER BY r.ref_id",
14019    )?;
14020    let rows = stmt.query_map(params![rel_path], |row| {
14021        Ok(RefDependencyRow {
14022            ref_id: row.get(0)?,
14023            kind: row.get(1)?,
14024            caller_file: row.get(2)?,
14025            module_path: row.get(3)?,
14026            target_file: row.get(4)?,
14027        })
14028    })?;
14029    let mut ids = Vec::new();
14030    for row in rows {
14031        let row = row?;
14032        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
14033            ids.push(DependentRefSelection {
14034                ref_id: row.ref_id,
14035                caller_file: row.caller_file,
14036            });
14037        }
14038    }
14039    Ok(ids)
14040}
14041
14042fn record_dependent_refs(
14043    selected_ref_ids: &mut BTreeSet<String>,
14044    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
14045    dependent_refs: Vec<DependentRefSelection>,
14046) {
14047    for dependent_ref in dependent_refs {
14048        let DependentRefSelection {
14049            ref_id,
14050            caller_file,
14051        } = dependent_ref;
14052        selected_ref_ids.insert(ref_id.clone());
14053        selected_refs_by_caller
14054            .entry(caller_file)
14055            .or_default()
14056            .insert(ref_id);
14057    }
14058}
14059
14060#[cfg(test)]
14061fn refs_by_caller_for_ref_ids(
14062    tx: &Transaction<'_>,
14063    ref_ids: &BTreeSet<String>,
14064) -> Result<BTreeMap<String, BTreeSet<String>>> {
14065    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
14066    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
14067    for ref_id in ref_ids {
14068        if let Some(caller) = stmt
14069            .query_row(params![ref_id], |row| row.get::<_, String>(0))
14070            .optional()?
14071        {
14072            by_caller.entry(caller).or_default().insert(ref_id.clone());
14073        }
14074    }
14075    Ok(by_caller)
14076}
14077
14078fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14079    tx.execute(
14080        "DELETE FROM file_dependencies WHERE file_path = ?1",
14081        params![rel_path],
14082    )?;
14083    delete_refs_for_caller(tx, rel_path)?;
14084    tx.execute(
14085        "DELETE FROM dispatch_hints WHERE file = ?1",
14086        params![rel_path],
14087    )?;
14088    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
14089    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
14090    Ok(())
14091}
14092
14093fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
14094    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
14095    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
14096    let mut ids = BTreeSet::new();
14097    for row in rows {
14098        ids.insert(row?);
14099    }
14100    delete_ref_ids(tx, &ids)
14101}
14102
14103fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
14104    let mut delete_edges = tx.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
14105    let mut delete_refs = tx.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
14106    for ref_id in ref_ids {
14107        delete_edges.execute(params![ref_id])?;
14108        delete_refs.execute(params![ref_id])?;
14109    }
14110    Ok(())
14111}
14112
14113fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
14114    let mut stmt = conn.prepare(
14115        "SELECT source.file_path, source.scoped_name, edges.target_file,
14116                edges.target_symbol, edges.kind, edges.line
14117         FROM edges
14118         JOIN nodes AS source ON source.id = edges.source_node
14119         ORDER BY source.file_path, source.scoped_name, edges.target_file,
14120                  edges.target_symbol, edges.kind, edges.line",
14121    )?;
14122    let rows = stmt.query_map([], |row| {
14123        Ok(StoredEdge {
14124            source_file: row.get(0)?,
14125            source_symbol: row.get(1)?,
14126            target_file: row.get(2)?,
14127            target_symbol: row.get(3)?,
14128            kind: row.get(4)?,
14129            line: row.get::<_, i64>(5)? as u32,
14130        })
14131    })?;
14132    let mut edges = BTreeSet::new();
14133    for row in rows {
14134        edges.insert(row?);
14135    }
14136    Ok(edges)
14137}
14138
14139fn module_target_from_dependencies(
14140    project_root: &Path,
14141    dependencies: &BTreeSet<String>,
14142    facts: &FactPaths<'_>,
14143) -> Option<String> {
14144    dependencies.iter().find_map(|dep| {
14145        let path = project_root.join(dep);
14146        if facts.is_file(&path) {
14147            Some(relative_path(
14148                project_root,
14149                &facts.canonical(&path).unwrap_or(path.clone()),
14150            ))
14151        } else {
14152            None
14153        }
14154    })
14155}
14156
14157fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
14158    let mut named = HashMap::new();
14159    if let Some(full_ref) = &raw_ref.full_ref {
14160        named = parse_reexport_names(full_ref);
14161    }
14162    ReexportIndex {
14163        target_file,
14164        named,
14165        wildcard: raw_ref.wildcard,
14166    }
14167}
14168
14169fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
14170    let mut names = HashMap::new();
14171    let Some(open) = statement.find('{') else {
14172        return names;
14173    };
14174    let Some(close) = statement[open + 1..]
14175        .find('}')
14176        .map(|offset| open + 1 + offset)
14177    else {
14178        return names;
14179    };
14180    for spec in statement[open + 1..close].split(',') {
14181        let spec = spec.trim();
14182        if spec.is_empty() {
14183            continue;
14184        }
14185        if let Some((source, local)) = spec.split_once(" as ") {
14186            names.insert(local.trim().to_string(), source.trim().to_string());
14187        } else {
14188            names.insert(spec.to_string(), spec.to_string());
14189        }
14190    }
14191    names
14192}
14193
14194#[derive(Debug)]
14195struct RefDependencyRow {
14196    ref_id: String,
14197    kind: String,
14198    caller_file: String,
14199    module_path: Option<String>,
14200    target_file: Option<String>,
14201}
14202
14203fn ref_dependency_row_depends_on(
14204    project_root: &Path,
14205    row: &RefDependencyRow,
14206    rel_path: &str,
14207) -> bool {
14208    if row.target_file.as_deref() == Some(rel_path) {
14209        return true;
14210    }
14211
14212    match row.kind.as_str() {
14213        "call" => true,
14214        "import" | "reexport" => row
14215            .module_path
14216            .as_deref()
14217            .map(|module_path| {
14218                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
14219                    .contains(rel_path)
14220            })
14221            .unwrap_or(false),
14222        "export_alias" => false,
14223        _ => false,
14224    }
14225}
14226
14227fn module_dependencies_for_ref(
14228    project_root: &Path,
14229    caller_file: &str,
14230    module_path: &str,
14231) -> BTreeSet<String> {
14232    module_dependencies(
14233        project_root,
14234        &project_root.join(caller_file),
14235        module_path,
14236        &FactPaths {
14237            root: project_root,
14238            facts: &DiskFacts::new(project_root),
14239        },
14240    )
14241}
14242
14243fn import_dependencies(
14244    project_root: &Path,
14245    abs_path: &Path,
14246    imports: &[ImportStatement],
14247    facts: &FactPaths<'_>,
14248) -> BTreeSet<String> {
14249    let mut deps = BTreeSet::new();
14250    for import in imports {
14251        deps.extend(module_dependencies(
14252            project_root,
14253            abs_path,
14254            &import.module_path,
14255            facts,
14256        ));
14257    }
14258    deps
14259}
14260
14261fn module_dependencies(
14262    project_root: &Path,
14263    abs_path: &Path,
14264    module_path: &str,
14265    facts: &FactPaths<'_>,
14266) -> BTreeSet<String> {
14267    let mut deps = rust_module_dependencies(project_root, abs_path, module_path, facts);
14268    let caller_dir = abs_path.parent().unwrap_or(project_root);
14269    if let Some(resolved) = callgraph::resolve_module_path_with_memo(
14270        caller_dir,
14271        module_path,
14272        &callgraph::ModuleResolutionMemo::default(),
14273        facts,
14274    ) {
14275        deps.insert(relative_path(project_root, &resolved));
14276    }
14277    if module_path.starts_with('.') {
14278        let base = caller_dir.join(module_path);
14279        for candidate in relative_module_candidates(&base) {
14280            deps.insert(relative_path(project_root, &candidate));
14281        }
14282    }
14283    deps
14284}
14285
14286fn rust_module_dependencies(
14287    project_root: &Path,
14288    abs_path: &Path,
14289    module_path: &str,
14290    facts: &FactPaths<'_>,
14291) -> BTreeSet<String> {
14292    let mut deps = BTreeSet::new();
14293    let rel_path = relative_path(
14294        project_root,
14295        &facts
14296            .canonical(abs_path)
14297            .unwrap_or_else(|| abs_path.to_path_buf()),
14298    );
14299    let Some(path_segments) = rust_module_dependency_segments(&rel_path, module_path) else {
14300        return deps;
14301    };
14302    let src_prefix = rust_src_prefix(&rel_path);
14303    rust_push_module_dependency_candidate(
14304        project_root,
14305        &mut deps,
14306        &src_prefix,
14307        &path_segments,
14308        facts,
14309    );
14310    if !path_segments.is_empty() {
14311        rust_push_module_dependency_candidate(
14312            project_root,
14313            &mut deps,
14314            &src_prefix,
14315            &path_segments[..path_segments.len() - 1],
14316            facts,
14317        );
14318    }
14319    deps
14320}
14321
14322fn rust_module_dependency_segments(rel_path: &str, module_path: &str) -> Option<Vec<String>> {
14323    let path = rust_module_path_without_alias_or_use_list(module_path);
14324    let segments = path
14325        .split("::")
14326        .map(str::trim)
14327        .filter(|segment| !segment.is_empty())
14328        .collect::<Vec<_>>();
14329    if segments.is_empty() || matches!(segments[0], "std" | "core" | "alloc") {
14330        return None;
14331    }
14332    rust_resolve_segments(rel_path, &segments)
14333}
14334
14335fn rust_module_path_without_alias_or_use_list(module_path: &str) -> &str {
14336    let path = module_path
14337        .trim()
14338        .trim_end_matches(';')
14339        .split_once(" as ")
14340        .map(|(left, _)| left.trim())
14341        .unwrap_or_else(|| module_path.trim().trim_end_matches(';'));
14342    path.find("::{").map(|brace| &path[..brace]).unwrap_or(path)
14343}
14344
14345fn rust_push_module_dependency_candidate(
14346    project_root: &Path,
14347    deps: &mut BTreeSet<String>,
14348    src_prefix: &str,
14349    segments: &[String],
14350    facts: &FactPaths<'_>,
14351) {
14352    let candidates = if segments.is_empty() {
14353        vec![
14354            format!("{src_prefix}/lib.rs"),
14355            format!("{src_prefix}/main.rs"),
14356        ]
14357    } else {
14358        vec![
14359            format!("{}/{}.rs", src_prefix, segments.join("/")),
14360            format!("{}/{}/mod.rs", src_prefix, segments.join("/")),
14361        ]
14362    };
14363    for candidate in candidates {
14364        if facts.is_file(&project_root.join(&candidate)) {
14365            deps.insert(candidate);
14366        }
14367    }
14368}
14369
14370fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
14371    let mut candidates = Vec::new();
14372    if base.extension().is_some() {
14373        candidates.push(base.to_path_buf());
14374        return candidates;
14375    }
14376    for ext in JS_TS_EXTENSIONS {
14377        candidates.push(base.with_extension(ext));
14378    }
14379    for ext in JS_TS_EXTENSIONS {
14380        candidates.push(base.join(format!("index.{ext}")));
14381    }
14382    candidates
14383}
14384
14385fn import_local_names(import: &ImportStatement) -> Vec<String> {
14386    let mut names = Vec::new();
14387    if let Some(default) = &import.default_import {
14388        names.push(default.clone());
14389    }
14390    if let Some(namespace) = &import.namespace_import {
14391        names.push(namespace.clone());
14392    }
14393    for name in &import.names {
14394        names.push(crate::imports::specifier_local_name(name).to_string());
14395    }
14396    names
14397}
14398
14399fn import_requested_names(import: &ImportStatement) -> Vec<String> {
14400    import
14401        .names
14402        .iter()
14403        .map(|name| crate::imports::specifier_imported_name(name).to_string())
14404        .collect()
14405}
14406
14407fn import_is_wildcard(import: &ImportStatement) -> bool {
14408    import.namespace_import.is_some() || import.raw_text.contains('*')
14409}
14410
14411fn namespace_alias(full_ref: &str) -> Option<String> {
14412    full_ref
14413        .split_once('.')
14414        .map(|(namespace, _)| namespace.to_string())
14415}
14416
14417fn import_kind_label(kind: ImportKind) -> &'static str {
14418    match kind {
14419        ImportKind::Value => "value",
14420        ImportKind::Type => "type",
14421        ImportKind::SideEffect => "side_effect",
14422    }
14423}
14424
14425fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
14426    match kind {
14427        SymbolKind::Function => "function",
14428        SymbolKind::Kernel => "kernel",
14429        SymbolKind::Class => "class",
14430        SymbolKind::Method => "method",
14431        SymbolKind::Struct => "struct",
14432        SymbolKind::Interface => "interface",
14433        SymbolKind::Enum => "enum",
14434        SymbolKind::TypeAlias => "type_alias",
14435        SymbolKind::Variable => "variable",
14436        SymbolKind::Heading => "heading",
14437        SymbolKind::FileSummary => "file_summary",
14438    }
14439}
14440
14441fn is_type_like(kind: &SymbolKind) -> bool {
14442    matches!(
14443        kind,
14444        SymbolKind::Class
14445            | SymbolKind::Struct
14446            | SymbolKind::Interface
14447            | SymbolKind::Enum
14448            | SymbolKind::TypeAlias
14449    )
14450}
14451
14452fn lang_label(lang: LangId) -> &'static str {
14453    match lang {
14454        LangId::TypeScript => "typescript",
14455        LangId::Tsx => "tsx",
14456        LangId::JavaScript => "javascript",
14457        LangId::Python => "python",
14458        LangId::Rust => "rust",
14459        LangId::Go => "go",
14460        LangId::C => "c",
14461        LangId::Cpp => "cpp",
14462        LangId::Cuda => "cuda",
14463        LangId::Metal => "metal",
14464        LangId::Zig => "zig",
14465        LangId::CSharp => "csharp",
14466        LangId::Bash => "bash",
14467        LangId::Html => "html",
14468        LangId::Markdown => "markdown",
14469        LangId::Solidity => "solidity",
14470        LangId::Scss => "scss",
14471        LangId::Vue => "vue",
14472        LangId::Json => "json",
14473        LangId::Scala => "scala",
14474        LangId::Java => "java",
14475        LangId::Ruby => "ruby",
14476        LangId::Kotlin => "kotlin",
14477        LangId::Swift => "swift",
14478        LangId::Php => "php",
14479        LangId::Lua => "lua",
14480        LangId::Perl => "perl",
14481        LangId::Yaml => "yaml",
14482        LangId::Pascal => "pascal",
14483        LangId::R => "r",
14484        LangId::Groovy => "groovy",
14485        LangId::ObjC => "objc",
14486        LangId::Toml => "toml",
14487    }
14488}
14489
14490fn lang_from_label(label: &str) -> Option<LangId> {
14491    match label {
14492        "typescript" => Some(LangId::TypeScript),
14493        "tsx" => Some(LangId::Tsx),
14494        "javascript" => Some(LangId::JavaScript),
14495        "python" => Some(LangId::Python),
14496        "rust" => Some(LangId::Rust),
14497        "go" => Some(LangId::Go),
14498        "c" => Some(LangId::C),
14499        "cpp" => Some(LangId::Cpp),
14500        "cuda" => Some(LangId::Cuda),
14501        "metal" => Some(LangId::Metal),
14502        "zig" => Some(LangId::Zig),
14503        "csharp" => Some(LangId::CSharp),
14504        "bash" => Some(LangId::Bash),
14505        "html" => Some(LangId::Html),
14506        "markdown" => Some(LangId::Markdown),
14507        "solidity" => Some(LangId::Solidity),
14508        "scss" => Some(LangId::Scss),
14509        "vue" => Some(LangId::Vue),
14510        "json" => Some(LangId::Json),
14511        "scala" => Some(LangId::Scala),
14512        "java" => Some(LangId::Java),
14513        "ruby" => Some(LangId::Ruby),
14514        "kotlin" => Some(LangId::Kotlin),
14515        "swift" => Some(LangId::Swift),
14516        "php" => Some(LangId::Php),
14517        "lua" => Some(LangId::Lua),
14518        "perl" => Some(LangId::Perl),
14519        "yaml" => Some(LangId::Yaml),
14520        "pascal" => Some(LangId::Pascal),
14521        "r" => Some(LangId::R),
14522        "groovy" => Some(LangId::Groovy),
14523        "objc" => Some(LangId::ObjC),
14524        "toml" => Some(LangId::Toml),
14525        _ => None,
14526    }
14527}
14528
14529fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
14530    let mut normalized = if files.is_empty() {
14531        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
14532    } else {
14533        files
14534            .iter()
14535            .map(|path| normalize_file_path(project_root, path))
14536            .collect::<Result<Vec<_>>>()?
14537    };
14538    normalized.sort();
14539    normalized.dedup();
14540    Ok(normalized)
14541}
14542
14543fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
14544    let full_path = if path.is_relative() {
14545        project_root.join(path)
14546    } else {
14547        path.to_path_buf()
14548    };
14549    Ok(canonicalize_path(&full_path))
14550}
14551
14552/// Normalize a refresh path against the store root before assigning its durable
14553/// relative key. Deleted watcher paths need lenient canonicalization: their
14554/// parent can still reveal an alias such as a symlinked project root.
14555fn normalize_project_file_path(project_root: &Path, path: &Path) -> Result<(PathBuf, String)> {
14556    let abs_path = normalize_file_path(project_root, path)?;
14557    let rel_path = relative_path(project_root, &abs_path);
14558    if Path::new(&rel_path).is_absolute() {
14559        return Err(CallGraphStoreError::PathIdentityMismatch {
14560            path: path.to_path_buf(),
14561            project_root: project_root.to_path_buf(),
14562        });
14563    }
14564    Ok((abs_path, rel_path))
14565}
14566
14567/// Canonicalize an existing path or the deepest existing ancestor of a deleted
14568/// one. This keeps watcher deletion events in the same identity domain as the
14569/// files indexed before the deletion.
14570fn canonicalize_path(path: &Path) -> PathBuf {
14571    if let Ok(canonical) = std::fs::canonicalize(path) {
14572        return canonical;
14573    }
14574
14575    let mut resolved = PathBuf::new();
14576    let mut missing = Vec::new();
14577    for component in path.components() {
14578        match component {
14579            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
14580                resolved.push(component.as_os_str());
14581                if let Ok(canonical) = std::fs::canonicalize(&resolved) {
14582                    resolved = canonical;
14583                }
14584            }
14585            std::path::Component::CurDir => {}
14586            std::path::Component::ParentDir => {
14587                if missing.pop().is_none() {
14588                    if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
14589                        return path.to_path_buf();
14590                    }
14591                    resolved.pop();
14592                }
14593            }
14594            std::path::Component::Normal(name) => {
14595                if missing.is_empty() {
14596                    let candidate = resolved.join(name);
14597                    match std::fs::canonicalize(&candidate) {
14598                        Ok(canonical) => resolved = canonical,
14599                        Err(_) => match std::fs::symlink_metadata(&candidate) {
14600                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14601                                missing.push(name.to_owned());
14602                            }
14603                            _ => return path.to_path_buf(),
14604                        },
14605                    }
14606                } else {
14607                    missing.push(name.to_owned());
14608                }
14609            }
14610        }
14611    }
14612    resolved.extend(missing);
14613    resolved
14614}
14615
14616fn relative_path(project_root: &Path, path: &Path) -> String {
14617    if let Ok(stripped) = path.strip_prefix(project_root) {
14618        return stripped.to_string_lossy().replace('\\', "/");
14619    }
14620    let canon_root = canonicalize_path(project_root);
14621    let canon_path = canonicalize_path(path);
14622    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
14623        return stripped.to_string_lossy().replace('\\', "/");
14624    }
14625    canon_path.to_string_lossy().replace('\\', "/")
14626}
14627
14628fn unqualified_name(scoped: &str) -> &str {
14629    if scoped == TOP_LEVEL_SYMBOL {
14630        return scoped;
14631    }
14632    scoped
14633        .rsplit("::")
14634        .next()
14635        .unwrap_or(scoped)
14636        .rsplit('.')
14637        .next()
14638        .unwrap_or(scoped)
14639        .rsplit('#')
14640        .next()
14641        .unwrap_or(scoped)
14642}
14643
14644fn ref_id(parts: &[&str]) -> String {
14645    let joined = parts.join("\0");
14646    hash_to_hex(blake3::hash(joined.as_bytes()))
14647}
14648
14649fn callgraph_corpus_fingerprint(project_root: &Path) -> Result<String> {
14650    let mut fingerprint = CorpusFingerprint::default();
14651    for path in callgraph::walk_project_files(project_root) {
14652        fingerprint.add_path(project_root, &path);
14653    }
14654    Ok(fingerprint.finish(project_root))
14655}
14656
14657/// Pre-admission fingerprint over the same source set the staging inventory
14658/// will consume: the walk when no explicit list is supplied, the list
14659/// otherwise. Streaming accumulator - no staging writes, bounded memory.
14660fn corpus_fingerprint_for(project_root: &Path, files: &[PathBuf]) -> Result<String> {
14661    if files.is_empty() {
14662        return callgraph_corpus_fingerprint(project_root);
14663    }
14664    let mut fingerprint = CorpusFingerprint::default();
14665    for path in files {
14666        fingerprint.add_path(project_root, path);
14667    }
14668    Ok(fingerprint.finish(project_root))
14669}
14670
14671#[derive(Default)]
14672struct CorpusFingerprint {
14673    xor: [u8; 32],
14674    sums: [u64; 4],
14675    files: u64,
14676}
14677
14678impl CorpusFingerprint {
14679    fn add_path(&mut self, project_root: &Path, path: &Path) {
14680        let mut record = blake3::Hasher::new();
14681        record.update(relative_path(project_root, path).as_bytes());
14682        record.update(&[0]);
14683        match hash_file_bounded(path) {
14684            Ok(content_hash) => record.update(content_hash.as_bytes()),
14685            // Encoding a missing file as a distinct record changes the corpus
14686            // fingerprint, so breaker state keyed to the previous corpus is not reused.
14687            Err(error) => record.update(format!("missing:{error}").as_bytes()),
14688        };
14689        record.update(&[0]);
14690        let record = record.finalize();
14691        for (index, byte) in record.as_bytes().iter().copied().enumerate() {
14692            self.xor[index] ^= byte;
14693        }
14694        for (index, chunk) in record.as_bytes().chunks_exact(8).enumerate() {
14695            let value = u64::from_le_bytes(chunk.try_into().expect("eight-byte digest chunk"));
14696            self.sums[index] = self.sums[index].wrapping_add(value);
14697        }
14698        self.files = self.files.saturating_add(1);
14699    }
14700
14701    fn finish(self, project_root: &Path) -> String {
14702        // Combining both xor and modular sums keeps the digest independent of
14703        // walk order while retaining duplicate sensitivity for generic callers.
14704        let mut hasher = blake3::Hasher::new();
14705        hasher.update(b"callgraph-corpus-fingerprint-v2\0");
14706        hasher.update(&self.files.to_le_bytes());
14707        hasher.update(&self.xor);
14708        for sum in self.sums {
14709            hasher.update(&sum.to_le_bytes());
14710        }
14711        let ignore_rules = project_root.join(".gitignore");
14712        if let Ok(contents) = std::fs::read(ignore_rules) {
14713            hasher.update(b".gitignore\0");
14714            hasher.update(blake3::hash(&contents).as_bytes());
14715        }
14716        hash_to_hex(hasher.finalize())
14717    }
14718}
14719
14720fn hash_file_bounded(path: &Path) -> std::io::Result<blake3::Hash> {
14721    let mut file = std::fs::File::open(path)?;
14722    let mut hasher = blake3::Hasher::new();
14723    let mut buffer = [0u8; 64 * 1024];
14724    loop {
14725        let read = file.read(&mut buffer)?;
14726        if read == 0 {
14727            break;
14728        }
14729        hasher.update(&buffer[..read]);
14730    }
14731    Ok(hasher.finalize())
14732}
14733
14734#[cfg(test)]
14735pub(crate) fn callgraph_corpus_fingerprint_for_test(
14736    project_root: &Path,
14737    _files: &[PathBuf],
14738) -> Result<String> {
14739    // The streaming fingerprint walks the corpus itself (order-independent
14740    // accumulator, no resident file list); the test seam keeps its historical
14741    // signature so callers need not thread a walk of their own.
14742    callgraph_corpus_fingerprint(project_root)
14743}
14744
14745fn hash_to_hex(hash: blake3::Hash) -> String {
14746    hash.to_hex().to_string()
14747}
14748
14749fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
14750    let bytes = hex_to_bytes(value)?;
14751    Some(blake3::Hash::from_bytes(bytes))
14752}
14753
14754fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
14755    if value.len() != 64 {
14756        return None;
14757    }
14758    let mut bytes = [0u8; 32];
14759    for (index, slot) in bytes.iter_mut().enumerate() {
14760        let start = index * 2;
14761        let end = start + 2;
14762        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
14763    }
14764    Some(bytes)
14765}
14766
14767#[derive(Debug, Clone)]
14768struct LineIndex {
14769    newline_offsets: Vec<usize>,
14770    source_len: usize,
14771}
14772
14773impl LineIndex {
14774    fn new(source: &str) -> Self {
14775        Self {
14776            newline_offsets: source
14777                .bytes()
14778                .enumerate()
14779                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
14780                .collect(),
14781            source_len: source.len(),
14782        }
14783    }
14784
14785    fn byte_to_line(&self, byte_offset: usize) -> u32 {
14786        let byte_offset = byte_offset.min(self.source_len);
14787        self.newline_offsets
14788            .partition_point(|offset| *offset < byte_offset) as u32
14789            + 1
14790    }
14791}
14792
14793fn empty_to_none(value: String) -> Option<String> {
14794    if value.is_empty() {
14795        None
14796    } else {
14797        Some(value)
14798    }
14799}
14800
14801fn bool_int(value: bool) -> i64 {
14802    if value {
14803        1
14804    } else {
14805        0
14806    }
14807}
14808
14809fn system_time_to_ns(time: SystemTime) -> i64 {
14810    time.duration_since(UNIX_EPOCH)
14811        .unwrap_or_default()
14812        .as_nanos()
14813        .min(i64::MAX as u128) as i64
14814}
14815
14816fn ns_to_system_time(value: i64) -> SystemTime {
14817    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
14818}
14819
14820pub(crate) fn unix_millis_now() -> u64 {
14821    SystemTime::now()
14822        .duration_since(UNIX_EPOCH)
14823        .unwrap_or_default()
14824        .as_millis()
14825        .min(u128::from(u64::MAX)) as u64
14826}
14827
14828fn unix_seconds_now() -> i64 {
14829    SystemTime::now()
14830        .duration_since(UNIX_EPOCH)
14831        .unwrap_or_default()
14832        .as_secs() as i64
14833}
14834
14835/// Serializes every test that drives the process-wide refresh worker
14836/// (enqueue/flush swap the shared worker slot; a concurrent flush can shut a
14837/// worker down between another test's enqueue and its flush, deferring the
14838/// batch and zeroing that test's seam counts).
14839#[cfg(test)]
14840pub(crate) static REFRESH_WORKER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
14841
14842#[cfg(test)]
14843mod refresh_worker_tests {
14844    use super::*;
14845    use std::fs;
14846    use tempfile::tempdir;
14847
14848    fn ready_store_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14849        let temp = tempdir().unwrap();
14850        let root = temp.path().join("root");
14851        fs::create_dir_all(&root).unwrap();
14852        let artifact_key = crate::search_index::artifact_cache_key(&root);
14853        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14854        let callgraph_dir = temp
14855            .path()
14856            .join("storage")
14857            .join("callgraph")
14858            .join(artifact_key);
14859        let source = root.join("main.rs");
14860        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n").unwrap();
14861        let (store, _) = CallGraphStore::cold_build_with_lease(
14862            callgraph_dir.clone(),
14863            root.clone(),
14864            std::slice::from_ref(&source),
14865        )
14866        .unwrap();
14867        drop(store);
14868        (temp, root, callgraph_dir, source)
14869    }
14870
14871    fn pending_paths() -> PendingCallGraphStorePaths {
14872        Arc::new(parking_lot::Mutex::new(BTreeSet::new()))
14873    }
14874
14875    fn wait_for_refresh_calls(root: &Path, expected: usize) {
14876        let deadline = Instant::now() + Duration::from_secs(12);
14877        while callgraph_refresh_worker_test_counts(root).0 < expected {
14878            assert!(
14879                Instant::now() < deadline,
14880                "timed out waiting for {expected} callgraph refresh worker call(s)"
14881            );
14882            std::thread::sleep(Duration::from_millis(5));
14883        }
14884    }
14885
14886    fn wait_for_refresh_worker_idle() {
14887        let deadline = Instant::now() + Duration::from_secs(12);
14888        loop {
14889            let worker = CALLGRAPH_REFRESH_WORKER
14890                .get_or_init(|| Mutex::new(None))
14891                .lock()
14892                .expect("callgraph refresh worker mutex poisoned")
14893                .clone();
14894            let idle = worker.is_none_or(|worker| {
14895                let queue = worker
14896                    .shared
14897                    .queue
14898                    .lock()
14899                    .expect("callgraph refresh queue mutex poisoned");
14900                queue.active.is_none() && queue.order.is_empty()
14901            });
14902            if idle {
14903                return;
14904            }
14905            assert!(
14906                Instant::now() < deadline,
14907                "timed out waiting for callgraph refresh worker to become idle"
14908            );
14909            std::thread::sleep(Duration::from_millis(5));
14910        }
14911    }
14912
14913    fn workspace_refresh_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
14914        let temp = tempdir().unwrap();
14915        let root = temp.path().join("workspace");
14916        fs::create_dir_all(root.join("app/src")).unwrap();
14917        let artifact_key = crate::search_index::artifact_cache_key(&root);
14918        crate::root_cache::configure_artifact_access(&root, &artifact_key, false);
14919        let callgraph_dir = temp
14920            .path()
14921            .join("storage")
14922            .join("callgraph")
14923            .join(artifact_key);
14924        fs::write(
14925            root.join("Cargo.toml"),
14926            "[workspace]\nmembers = [\"app\"]\nresolver = \"2\"\n",
14927        )
14928        .unwrap();
14929        fs::write(
14930            root.join("app/Cargo.toml"),
14931            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
14932        )
14933        .unwrap();
14934        let caller = root.join("app/src/lib.rs");
14935        fs::write(&caller, "pub fn run() { added_crate::target(); }\n").unwrap();
14936        let (store, _) = CallGraphStore::cold_build_with_lease(
14937            callgraph_dir.clone(),
14938            root.clone(),
14939            std::slice::from_ref(&caller),
14940        )
14941        .unwrap();
14942        drop(store);
14943        (temp, root, callgraph_dir, caller)
14944    }
14945
14946    #[test]
14947    fn refresh_worker_reuses_workspace_prefix_cache_for_one_root() {
14948        let _guard = REFRESH_WORKER_TEST_LOCK
14949            .lock()
14950            .unwrap_or_else(std::sync::PoisonError::into_inner);
14951        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14952        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14953        reset_workspace_crate_prefix_build_count(&root);
14954        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14955
14956        for revision in ["first", "second"] {
14957            fs::write(
14958                &caller,
14959                format!("pub fn run() {{ added_crate::target(); }}\n// {revision}\n"),
14960            )
14961            .unwrap();
14962            enqueue_callgraph_store_refresh(
14963                callgraph_dir.clone(),
14964                root.clone(),
14965                vec![caller.clone()],
14966                pending_paths(),
14967            );
14968            wait_for_refresh_worker_idle();
14969        }
14970
14971        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
14972        assert!(flush_callgraph_store_refreshes_with_budget(
14973            Duration::from_secs(5)
14974        ));
14975        clear_callgraph_refresh_worker_test_seam(&root);
14976    }
14977
14978    #[test]
14979    fn manifest_event_rebuilds_workspace_prefix_cache_and_resolves_new_crate() {
14980        let _guard = REFRESH_WORKER_TEST_LOCK
14981            .lock()
14982            .unwrap_or_else(std::sync::PoisonError::into_inner);
14983        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
14984        let (_temp, root, callgraph_dir, caller) = workspace_refresh_fixture();
14985        reset_workspace_crate_prefix_build_count(&root);
14986        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
14987
14988        fs::write(
14989            &caller,
14990            "pub fn run() { added_crate::target(); }\n// prime missing-crate map\n",
14991        )
14992        .unwrap();
14993        enqueue_callgraph_store_refresh(
14994            callgraph_dir.clone(),
14995            root.clone(),
14996            vec![caller.clone()],
14997            pending_paths(),
14998        );
14999        wait_for_refresh_worker_idle();
15000        assert_eq!(workspace_crate_prefix_build_count(&root), 1);
15001
15002        let added_manifest = root.join("added/Cargo.toml");
15003        let added_source = root.join("added/src/lib.rs");
15004        fs::create_dir_all(added_source.parent().unwrap()).unwrap();
15005        fs::write(
15006            root.join("Cargo.toml"),
15007            "[workspace]\nmembers = [\"app\", \"added\"]\nresolver = \"2\"\n",
15008        )
15009        .unwrap();
15010        fs::write(
15011            &added_manifest,
15012            "[package]\nname = \"added-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
15013        )
15014        .unwrap();
15015        fs::write(&added_source, "pub fn target() {}\n").unwrap();
15016        fs::write(
15017            &caller,
15018            "pub fn run() { added_crate::target(); }\n// resolve added crate\n",
15019        )
15020        .unwrap();
15021
15022        enqueue_callgraph_store_refresh(
15023            callgraph_dir.clone(),
15024            root.clone(),
15025            vec![
15026                root.join("Cargo.toml"),
15027                added_manifest,
15028                added_source,
15029                caller,
15030            ],
15031            pending_paths(),
15032        );
15033        assert!(flush_callgraph_store_refreshes_with_budget(
15034            Duration::from_secs(12)
15035        ));
15036
15037        // This is the negative control for a permanently-static cache: without
15038        // manifest invalidation the build count stays at one and the call remains
15039        // unresolved because `added_crate` was absent when the map was primed.
15040        assert_eq!(workspace_crate_prefix_build_count(&root), 2);
15041        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15042            .unwrap()
15043            .expect("refreshed workspace store");
15044        let tree = store
15045            .call_tree(Path::new("app/src/lib.rs"), "run", 1)
15046            .unwrap();
15047        assert_eq!(tree.children.len(), 1);
15048        assert_eq!(tree.children[0].file, "added/src/lib.rs");
15049        assert_eq!(tree.children[0].name, "target");
15050        assert!(tree.children[0].resolved);
15051        clear_callgraph_refresh_worker_test_seam(&root);
15052    }
15053
15054    fn linked_worktree_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, PathBuf) {
15055        let temp = tempdir().unwrap();
15056        let main = temp.path().join("main");
15057        let worktree = temp.path().join("worktree");
15058        fs::create_dir_all(&main).unwrap();
15059        let mut git = std::process::Command::new("git");
15060        assert!(
15061            crate::test_env::apply_hermetic_git_env(git.arg("init").arg(&main))
15062                .status()
15063                .unwrap()
15064                .success()
15065        );
15066        fs::write(main.join("lib.rs"), "pub fn marker() {}\n").unwrap();
15067        for args in [
15068            vec![
15069                "-C",
15070                main.to_str().unwrap(),
15071                "config",
15072                "user.email",
15073                "test@example.com",
15074            ],
15075            vec![
15076                "-C",
15077                main.to_str().unwrap(),
15078                "config",
15079                "user.name",
15080                "AFT Test",
15081            ],
15082            vec!["-C", main.to_str().unwrap(), "add", "lib.rs"],
15083            vec!["-C", main.to_str().unwrap(), "commit", "-m", "fixture"],
15084        ] {
15085            let mut command = std::process::Command::new("git");
15086            assert!(crate::test_env::apply_hermetic_git_env(command.args(args))
15087                .status()
15088                .unwrap()
15089                .success());
15090        }
15091        let mut add_worktree = std::process::Command::new("git");
15092        assert!(crate::test_env::apply_hermetic_git_env(
15093            add_worktree
15094                .arg("-C")
15095                .arg(&main)
15096                .args(["worktree", "add", "--detach"])
15097                .arg(&worktree),
15098        )
15099        .status()
15100        .unwrap()
15101        .success());
15102        let main = fs::canonicalize(main).unwrap();
15103        let worktree = fs::canonicalize(worktree).unwrap();
15104        let project_key = crate::search_index::artifact_cache_key(&main);
15105        assert_eq!(
15106            crate::search_index::artifact_cache_key(&worktree),
15107            project_key
15108        );
15109        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15110        (temp, main, worktree, project_key, callgraph_dir)
15111    }
15112
15113    #[test]
15114    fn linked_worktree_never_acquires_writer_or_publishes_any_build_path() {
15115        let _git_env = crate::test_env::hermetic_git_env_guard();
15116        let (_temp, _main, root, project_key, callgraph_dir) = linked_worktree_fixture();
15117        crate::root_cache::configure_artifact_access(&root, &project_key, true);
15118        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15119        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15120        let publications_for_observer = Arc::clone(&publications);
15121        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15122            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15123        })));
15124        let source = root.join("lib.rs");
15125
15126        let open_error = CallGraphStore::open(callgraph_dir.clone(), root.clone())
15127            .expect_err("borrow-only writable open must remain unavailable");
15128        assert!(matches!(open_error, CallGraphStoreError::Unavailable(_)));
15129        assert!(
15130            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), root.clone())
15131                .unwrap()
15132                .is_none()
15133        );
15134        assert!(
15135            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), root.clone())
15136                .unwrap()
15137                .is_none()
15138        );
15139        assert!(matches!(
15140            CallGraphStore::cold_build_with_lease(
15141                callgraph_dir.clone(),
15142                root.clone(),
15143                std::slice::from_ref(&source),
15144            ),
15145            Err(CallGraphStoreError::Unavailable(_))
15146        ));
15147        assert!(matches!(
15148            CallGraphStore::ensure_built_with_lease(
15149                callgraph_dir.clone(),
15150                root.clone(),
15151                std::slice::from_ref(&source),
15152            ),
15153            Err(CallGraphStoreError::Unavailable(_))
15154        ));
15155        let force_error = CallGraphStore::force_cold_build_with_lease_chunked(
15156            callgraph_dir.clone(),
15157            root.clone(),
15158            &[source],
15159            1,
15160        )
15161        .expect_err("borrow-only forced rebuild must remain unsatisfied");
15162        set_cold_build_swap_observer(None);
15163
15164        assert!(matches!(force_error, CallGraphStoreError::Unavailable(_)));
15165        assert_eq!(
15166            crate::root_cache::writer_lease_acquisition_count_for_test(
15167                crate::root_cache::RootCacheDomain::Callgraph,
15168                &project_key,
15169                &root,
15170            ),
15171            0
15172        );
15173        assert_eq!(publications.load(AtomicOrdering::SeqCst), 0);
15174        assert!(!pointer_path(&callgraph_dir, &project_key).exists());
15175    }
15176
15177    #[test]
15178    fn owner_and_linked_worktree_alternation_rebuilds_storm_generation_once() {
15179        let _git_env = crate::test_env::hermetic_git_env_guard();
15180        let (_temp, owner, worktree, project_key, callgraph_dir) = linked_worktree_fixture();
15181        crate::root_cache::configure_artifact_access(&owner, &project_key, false);
15182        crate::root_cache::configure_artifact_access(&worktree, &project_key, true);
15183        let source = owner.join("lib.rs");
15184        let (store, _) = CallGraphStore::cold_build_with_lease(
15185            callgraph_dir.clone(),
15186            owner.clone(),
15187            std::slice::from_ref(&source),
15188        )
15189        .unwrap();
15190        let sqlite_path = store.sqlite_path().to_path_buf();
15191        drop(store);
15192
15193        let conn = Connection::open(&sqlite_path).unwrap();
15194        conn.execute(
15195            "UPDATE backend_file_state SET workspace_root = ?1",
15196            [worktree.display().to_string()],
15197        )
15198        .unwrap();
15199        drop(conn);
15200
15201        let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15202        let publications_for_observer = Arc::clone(&publications);
15203        set_cold_build_swap_observer(Some(Arc::new(move |_, _| {
15204            publications_for_observer.fetch_add(1, AtomicOrdering::SeqCst);
15205        })));
15206        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
15207
15208        let repaired = CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15209            .unwrap()
15210            .expect("owner should purge the storm-era worktree root");
15211        drop(repaired);
15212        for _ in 0..3 {
15213            let borrower = CallGraphStore::open_readonly(callgraph_dir.clone(), worktree.clone())
15214                .unwrap()
15215                .expect("linked worktree should borrow the owner generation");
15216            drop(borrower);
15217            assert!(
15218                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), worktree.clone())
15219                    .unwrap()
15220                    .is_none()
15221            );
15222            let owner_store =
15223                CallGraphStore::open_ready_repairing(callgraph_dir.clone(), owner.clone())
15224                    .unwrap()
15225                    .expect("owner generation should remain ready");
15226            drop(owner_store);
15227        }
15228        set_cold_build_swap_observer(None);
15229
15230        assert_eq!(
15231            publications.load(AtomicOrdering::SeqCst),
15232            1,
15233            "the owner performs one expected post-storm purge and alternation stays read-only"
15234        );
15235        assert_eq!(
15236            crate::root_cache::writer_lease_acquisition_count_for_test(
15237                crate::root_cache::RootCacheDomain::Callgraph,
15238                &project_key,
15239                &worktree,
15240            ),
15241            0
15242        );
15243    }
15244
15245    #[test]
15246    fn rebuild_cooldown_records_only_successful_publication_per_cache_key() {
15247        let temp = tempdir().unwrap();
15248        let root = temp.path().join("owner");
15249        let other_root = temp.path().join("other");
15250        fs::create_dir_all(&root).unwrap();
15251        fs::create_dir_all(&other_root).unwrap();
15252        let source = root.join("lib.rs");
15253        fs::write(&source, "pub fn marker() {}\n").unwrap();
15254        let project_key = crate::search_index::artifact_cache_key(&root);
15255        let callgraph_dir = temp.path().join("callgraph").join(&project_key);
15256        crate::root_cache::configure_artifact_access(&root, &project_key, false);
15257        let cooldown_key = rebuild_cooldown_key(&callgraph_dir, &project_key);
15258        rebuild_cooldown_records()
15259            .lock()
15260            .unwrap_or_else(std::sync::PoisonError::into_inner)
15261            .remove(&cooldown_key);
15262        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
15263        let stale_epoch = epoch.current();
15264        epoch.next();
15265
15266        let failed = with_publish_epoch(epoch, stale_epoch, || {
15267            CallGraphStore::cold_build_with_lease(
15268                callgraph_dir.clone(),
15269                root.clone(),
15270                std::slice::from_ref(&source),
15271            )
15272        });
15273        assert!(matches!(failed, Err(CallGraphStoreError::Superseded)));
15274        assert!(
15275            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15276                .is_none()
15277        );
15278
15279        let (store, _) = CallGraphStore::cold_build_with_lease(
15280            callgraph_dir.clone(),
15281            root.clone(),
15282            std::slice::from_ref(&source),
15283        )
15284        .unwrap();
15285        drop(store);
15286        assert!(
15287            rebuild_cooldown_denial(&callgraph_dir, &project_key, &other_root, Instant::now(),)
15288                .is_none()
15289        );
15290
15291        record_successful_rebuild(&callgraph_dir, &project_key, &other_root, Instant::now());
15292        assert!(
15293            rebuild_cooldown_denial(&callgraph_dir, &project_key, &root, Instant::now(),).is_some()
15294        );
15295    }
15296
15297    #[test]
15298    fn fenced_refresh_with_stale_lifecycle_generation_defers_paths_without_commit() {
15299        let _guard = REFRESH_WORKER_TEST_LOCK
15300            .lock()
15301            .unwrap_or_else(std::sync::PoisonError::into_inner);
15302        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15303        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15304        let pending = pending_paths();
15305        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15306
15307        let lifecycle = SubcLifecycleAdmission::default();
15308        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15309        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15310        let ticket = CallgraphRefreshTicket::new(
15311            lifecycle,
15312            Arc::clone(&generation),
15313            7,
15314            publish_epoch.clone(),
15315            publish_epoch.current(),
15316        );
15317        // Supersede before the worker runs: the batch must defer, not commit.
15318        generation.store(8, std::sync::atomic::Ordering::SeqCst);
15319        let installed = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15320            .unwrap()
15321            .expect("ready store snapshot");
15322        let refresh_state = CallgraphRefreshState::new(
15323            Arc::new(std::sync::RwLock::new(Some(Arc::new(installed)))),
15324            Arc::new(AtomicBool::new(true)),
15325        );
15326
15327        enqueue_callgraph_store_refresh_fenced_with_state(
15328            callgraph_dir,
15329            root.clone(),
15330            vec![source.clone()],
15331            Arc::clone(&pending),
15332            refresh_state,
15333            ticket,
15334        );
15335        assert!(flush_callgraph_store_refreshes_with_budget(
15336            Duration::from_secs(5)
15337        ));
15338        assert_eq!(
15339            callgraph_refresh_worker_test_counts(&root).0,
15340            0,
15341            "superseded batch must not reach refresh_files or self-replay"
15342        );
15343        assert!(
15344            pending.lock().contains(&source),
15345            "superseded batch must defer its paths to the pending sink"
15346        );
15347        clear_callgraph_refresh_worker_test_seam(&root);
15348    }
15349
15350    #[test]
15351    fn superseded_open_failure_defers_without_self_replay() {
15352        let _guard = REFRESH_WORKER_TEST_LOCK
15353            .lock()
15354            .unwrap_or_else(std::sync::PoisonError::into_inner);
15355        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15356        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15357        let pending = pending_paths();
15358        let installed = Arc::new(
15359            CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
15360                .unwrap()
15361                .expect("ready store snapshot"),
15362        );
15363        let refresh_state = CallgraphRefreshState::new(
15364            Arc::new(std::sync::RwLock::new(Some(Arc::clone(&installed)))),
15365            Arc::new(AtomicBool::new(true)),
15366        );
15367        assert!(!installed.is_legacy_fallback());
15368        assert!(installed.is_current());
15369        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15370        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15371        set_callgraph_refresh_worker_test_open_failure(root.clone(), true);
15372        let (held_rx, release_tx) = install_callgraph_refresh_worker_test_gate(root.clone());
15373
15374        let lifecycle = SubcLifecycleAdmission::default();
15375        let generation = Arc::new(std::sync::atomic::AtomicU64::new(7));
15376        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15377        let ticket = CallgraphRefreshTicket::new(
15378            lifecycle,
15379            Arc::clone(&generation),
15380            7,
15381            publish_epoch.clone(),
15382            publish_epoch.current(),
15383        );
15384        enqueue_callgraph_store_refresh_fenced_with_state(
15385            callgraph_dir,
15386            root.clone(),
15387            vec![source.clone()],
15388            Arc::clone(&pending),
15389            refresh_state,
15390            ticket,
15391        );
15392        held_rx
15393            .recv_timeout(Duration::from_secs(12))
15394            .expect("refresh worker must hold after injected open failure");
15395
15396        // Mark the refresh request obsolete after the injected open failure,
15397        // then unblock the worker before its deferred retry can run.
15398        generation.store(8, std::sync::atomic::Ordering::SeqCst);
15399        set_callgraph_refresh_worker_test_open_failure(root.clone(), false);
15400        release_tx
15401            .send(())
15402            .expect("release superseded refresh worker");
15403        wait_for_refresh_worker_idle();
15404
15405        assert_eq!(
15406            callgraph_refresh_worker_test_counts(&root).0,
15407            1,
15408            "superseded open-failure batch must not self-replay"
15409        );
15410        assert_eq!(
15411            callgraph_refresh_worker_test_worker_calls(&root),
15412            1,
15413            "superseded open-failure batch must not create another worker call"
15414        );
15415        assert!(
15416            pending.lock().contains(&source),
15417            "superseded open-failure paths must remain in the pending sink"
15418        );
15419        let tree = installed
15420            .call_tree(Path::new("main.rs"), "entry", 1)
15421            .unwrap();
15422        assert_eq!(
15423            tree.children[0].name, "old_leaf",
15424            "superseded open-failure batch must not converge the store"
15425        );
15426        clear_callgraph_refresh_worker_test_seam(&root);
15427    }
15428
15429    #[test]
15430    fn fenced_refresh_with_advanced_publish_epoch_defers_paths_without_commit() {
15431        let _guard = REFRESH_WORKER_TEST_LOCK
15432            .lock()
15433            .unwrap_or_else(std::sync::PoisonError::into_inner);
15434        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15435        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15436        let pending = pending_paths();
15437        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15438
15439        let lifecycle = SubcLifecycleAdmission::default();
15440        let generation = Arc::new(std::sync::atomic::AtomicU64::new(3));
15441        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15442        let expected_epoch = publish_epoch.current();
15443        let ticket = CallgraphRefreshTicket::new(
15444            lifecycle,
15445            generation,
15446            3,
15447            publish_epoch.clone(),
15448            expected_epoch,
15449        );
15450        // A cold build published a replacement generation after enqueue.
15451        publish_epoch.next();
15452
15453        enqueue_callgraph_store_refresh_fenced(
15454            callgraph_dir,
15455            root.clone(),
15456            vec![source.clone()],
15457            Arc::clone(&pending),
15458            ticket,
15459        );
15460        assert!(flush_callgraph_store_refreshes_with_budget(
15461            Duration::from_secs(5)
15462        ));
15463        assert_eq!(
15464            callgraph_refresh_worker_test_counts(&root).0,
15465            0,
15466            "epoch-superseded batch must not reach refresh_files"
15467        );
15468        assert!(
15469            pending.lock().contains(&source),
15470            "epoch-superseded batch must defer its paths to the pending sink"
15471        );
15472        clear_callgraph_refresh_worker_test_seam(&root);
15473    }
15474
15475    #[test]
15476    fn fenced_refresh_with_current_ticket_commits_normally() {
15477        let _guard = REFRESH_WORKER_TEST_LOCK
15478            .lock()
15479            .unwrap_or_else(std::sync::PoisonError::into_inner);
15480        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15481        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15482        let pending = pending_paths();
15483        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15484
15485        fs::write(&source, "fn entry() { new_leaf(); }\nfn new_leaf() {}\n").unwrap();
15486
15487        let lifecycle = SubcLifecycleAdmission::default();
15488        let generation = Arc::new(std::sync::atomic::AtomicU64::new(5));
15489        let publish_epoch = crate::root_cache::ArtifactPublishEpoch::default();
15490        let ticket = CallgraphRefreshTicket::new(
15491            lifecycle,
15492            generation,
15493            5,
15494            publish_epoch.clone(),
15495            publish_epoch.current(),
15496        );
15497
15498        enqueue_callgraph_store_refresh_fenced(
15499            callgraph_dir.clone(),
15500            root.clone(),
15501            vec![source.clone()],
15502            Arc::clone(&pending),
15503            ticket,
15504        );
15505        assert!(flush_callgraph_store_refreshes_with_budget(
15506            Duration::from_secs(5)
15507        ));
15508        assert_eq!(
15509            callgraph_refresh_worker_test_counts(&root).0,
15510            1,
15511            "current ticket must run the refresh"
15512        );
15513        assert!(
15514            pending.lock().is_empty(),
15515            "committed batch must not defer paths"
15516        );
15517
15518        let store = CallGraphStore::open_readonly(callgraph_dir, root.clone())
15519            .unwrap()
15520            .expect("published generation must remain readable");
15521        let tree = store.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15522        assert_eq!(
15523            tree.children[0].name, "new_leaf",
15524            "fenced commit must actually persist the refreshed content"
15525        );
15526        clear_callgraph_refresh_worker_test_seam(&root);
15527    }
15528
15529    #[test]
15530    fn queued_batches_for_one_root_coalesce_while_worker_is_busy() {
15531        let _guard = REFRESH_WORKER_TEST_LOCK
15532            .lock()
15533            .unwrap_or_else(std::sync::PoisonError::into_inner);
15534        // Generous pre-drain: the refresh worker is process-wide, so a prior
15535        // test's still-running batch (slow Windows CI) must fully settle
15536        // before this test enqueues, or its wait deadline absorbs the
15537        // leftover work. Idle workers return immediately.
15538        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15539        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15540        let pending = pending_paths();
15541        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::from_millis(150), false);
15542
15543        enqueue_callgraph_store_refresh(
15544            callgraph_dir.clone(),
15545            root.clone(),
15546            vec![source.clone()],
15547            Arc::clone(&pending),
15548        );
15549        wait_for_refresh_calls(&root, 1);
15550        for _ in 0..3 {
15551            enqueue_callgraph_store_refresh(
15552                callgraph_dir.clone(),
15553                root.clone(),
15554                vec![source.clone()],
15555                Arc::clone(&pending),
15556            );
15557        }
15558
15559        assert!(flush_callgraph_store_refreshes_with_budget(
15560            Duration::from_secs(2)
15561        ));
15562        assert_eq!(callgraph_refresh_worker_test_counts(&root).0, 2);
15563        assert!(pending.lock().is_empty());
15564        clear_callgraph_refresh_worker_test_seam(&root);
15565    }
15566
15567    #[test]
15568    fn queued_refresh_opens_generation_published_after_enqueue() {
15569        let _guard = REFRESH_WORKER_TEST_LOCK
15570            .lock()
15571            .unwrap_or_else(std::sync::PoisonError::into_inner);
15572        // Generous pre-drain: the refresh worker is process-wide, so a prior
15573        // test's still-running batch (slow Windows CI) must fully settle
15574        // before this test enqueues, or its wait deadline absorbs the
15575        // leftover work. Idle workers return immediately.
15576        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15577        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15578        let (_target_temp, target_root, target_dir, target_source) = ready_store_fixture();
15579        set_callgraph_refresh_worker_test_seam(active_root.clone(), Duration::ZERO, false);
15580        let (active_held_rx, active_release_tx) =
15581            install_callgraph_refresh_worker_test_gate(active_root.clone());
15582        set_callgraph_refresh_worker_test_seam(target_root.clone(), Duration::ZERO, false);
15583        enqueue_callgraph_store_refresh(
15584            active_dir,
15585            active_root.clone(),
15586            vec![active_source],
15587            pending_paths(),
15588        );
15589        active_held_rx
15590            .recv_timeout(Duration::from_secs(12))
15591            .expect("active refresh worker holds the queue");
15592
15593        fs::write(
15594            &target_source,
15595            "fn entry() { build_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15596        )
15597        .unwrap();
15598        enqueue_callgraph_store_refresh(
15599            target_dir.clone(),
15600            target_root.clone(),
15601            vec![target_source.clone()],
15602            pending_paths(),
15603        );
15604        let (new_generation, _) = CallGraphStore::cold_build_with_lease(
15605            target_dir.clone(),
15606            target_root.clone(),
15607            std::slice::from_ref(&target_source),
15608        )
15609        .unwrap();
15610        fs::write(
15611            &target_source,
15612            "fn entry() { worker_leaf(); }\nfn build_leaf() {}\nfn worker_leaf() {}\n",
15613        )
15614        .unwrap();
15615        drop(new_generation);
15616
15617        active_release_tx
15618            .send(())
15619            .expect("release active refresh worker");
15620        wait_for_refresh_calls(&target_root, 1);
15621        assert!(flush_callgraph_store_refreshes_with_budget(
15622            Duration::from_secs(12)
15623        ));
15624        let current = CallGraphStore::open_readonly(target_dir, target_root.clone())
15625            .unwrap()
15626            .expect("current callgraph generation");
15627        let tree = current.call_tree(Path::new("main.rs"), "entry", 1).unwrap();
15628        assert_eq!(tree.children[0].name, "worker_leaf");
15629        assert_eq!(callgraph_refresh_worker_test_counts(&target_root).0, 1);
15630        clear_callgraph_refresh_worker_test_seam(&active_root);
15631        clear_callgraph_refresh_worker_test_seam(&target_root);
15632    }
15633
15634    #[test]
15635    fn refresh_failure_marks_files_stale() {
15636        let _guard = REFRESH_WORKER_TEST_LOCK
15637            .lock()
15638            .unwrap_or_else(std::sync::PoisonError::into_inner);
15639        // Generous pre-drain: the refresh worker is process-wide, so a prior
15640        // test's still-running batch (slow Windows CI) must fully settle
15641        // before this test enqueues, or its wait deadline absorbs the
15642        // leftover work. Idle workers return immediately.
15643        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15644        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15645        let pending = pending_paths();
15646        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, true);
15647
15648        enqueue_callgraph_store_refresh(callgraph_dir.clone(), root.clone(), vec![source], pending);
15649        assert!(flush_callgraph_store_refreshes_with_budget(
15650            Duration::from_secs(2)
15651        ));
15652
15653        assert_eq!(callgraph_refresh_worker_test_counts(&root), (1, 1));
15654        let store = CallGraphStore::open_ready(callgraph_dir, root.clone())
15655            .unwrap()
15656            .expect("ready callgraph store");
15657        assert_eq!(store.stale_files().unwrap(), vec!["main.rs"]);
15658        clear_callgraph_refresh_worker_test_seam(&root);
15659    }
15660
15661    #[test]
15662    fn idle_refresh_truncates_wal() {
15663        let _guard = REFRESH_WORKER_TEST_LOCK
15664            .lock()
15665            .unwrap_or_else(std::sync::PoisonError::into_inner);
15666        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15667        let (_temp, root, callgraph_dir, source) = ready_store_fixture();
15668        let generation = read_pointer(
15669            &callgraph_dir,
15670            &crate::search_index::artifact_cache_key(&root),
15671        )
15672        .expect("fixture publishes a generation");
15673        let wal_path = callgraph_dir.join(format!("{generation}-wal"));
15674        let pending = pending_paths();
15675        set_callgraph_refresh_worker_test_seam(root.clone(), Duration::ZERO, false);
15676
15677        fs::write(&source, "fn entry() { old_leaf(); }\nfn old_leaf() {}\n\n").unwrap();
15678        enqueue_callgraph_store_refresh(
15679            callgraph_dir.clone(),
15680            root.clone(),
15681            vec![source.clone()],
15682            Arc::clone(&pending),
15683        );
15684        wait_for_refresh_calls(&root, 1);
15685        wait_for_refresh_worker_idle();
15686        let checkpoint_deadline = Instant::now() + Duration::from_secs(2);
15687        while fs::metadata(&wal_path)
15688            .map(|metadata| metadata.len())
15689            .unwrap_or(0)
15690            != 0
15691        {
15692            assert!(
15693                Instant::now() < checkpoint_deadline,
15694                "idle checkpoint did not truncate WAL"
15695            );
15696            std::thread::sleep(Duration::from_millis(5));
15697        }
15698        assert_eq!(
15699            fs::metadata(&wal_path)
15700                .map(|metadata| metadata.len())
15701                .unwrap_or(0),
15702            0,
15703            "idle transition truncates the refresh WAL"
15704        );
15705
15706        clear_callgraph_refresh_worker_test_seam(&root);
15707    }
15708
15709    #[test]
15710    fn bounded_shutdown_defers_unprocessed_batches() {
15711        let _guard = REFRESH_WORKER_TEST_LOCK
15712            .lock()
15713            .unwrap_or_else(std::sync::PoisonError::into_inner);
15714        // Generous pre-drain: the refresh worker is process-wide, so a prior
15715        // test's still-running batch (slow Windows CI) must fully settle
15716        // before this test enqueues, or its wait deadline absorbs the
15717        // leftover work. Idle workers return immediately.
15718        let _ = flush_callgraph_store_refreshes_with_budget(Duration::from_secs(30));
15719        let (_active_temp, active_root, active_dir, active_source) = ready_store_fixture();
15720        let (_queued_temp, queued_root, queued_dir, queued_source) = ready_store_fixture();
15721        let active_pending = pending_paths();
15722        let queued_pending = pending_paths();
15723        set_callgraph_refresh_worker_test_seam(
15724            active_root.clone(),
15725            Duration::from_millis(300),
15726            false,
15727        );
15728
15729        enqueue_callgraph_store_refresh(
15730            active_dir,
15731            active_root.clone(),
15732            vec![active_source.clone()],
15733            Arc::clone(&active_pending),
15734        );
15735        wait_for_refresh_calls(&active_root, 1);
15736        enqueue_callgraph_store_refresh(
15737            queued_dir,
15738            queued_root.clone(),
15739            vec![queued_source.clone()],
15740            Arc::clone(&queued_pending),
15741        );
15742
15743        assert!(!flush_callgraph_store_refreshes_with_budget(
15744            Duration::from_millis(20)
15745        ));
15746        assert!(active_pending.lock().contains(&active_source));
15747        assert!(queued_pending.lock().contains(&queued_source));
15748        assert_eq!(callgraph_refresh_worker_test_counts(&queued_root).0, 0);
15749        clear_callgraph_refresh_worker_test_seam(&active_root);
15750    }
15751}
15752
15753#[cfg(test)]
15754mod cold_build_insert_tests {
15755    use super::*;
15756    use crate::imports::ImportBlock;
15757    use std::cell::Cell;
15758    use std::fs;
15759    use std::path::{Path, PathBuf};
15760    use tempfile::tempdir;
15761
15762    thread_local! {
15763        static CALLER_QUERY_SELECTS: Cell<usize> = const { Cell::new(0) };
15764        static BOUNDARY_COUNT_SELECTS: Cell<usize> = const { Cell::new(0) };
15765        static TOTAL_CALLER_TRAVERSAL_SELECTS: Cell<usize> = const { Cell::new(0) };
15766    }
15767
15768    fn count_caller_traversal_selects(sql: &str) {
15769        let sql = sql.trim_start();
15770        if sql.starts_with("SELECT") || sql.starts_with("WITH requested") {
15771            TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(count.get() + 1));
15772        }
15773        if sql.contains("SELECT e.target_file, e.target_symbol, e.line")
15774            && sql.contains("e.target_file =")
15775        {
15776            CALLER_QUERY_SELECTS.with(|count| count.set(count.get() + 1));
15777        }
15778        if sql.starts_with("WITH requested") && sql.contains("COUNT(*)") {
15779            BOUNDARY_COUNT_SELECTS.with(|count| count.set(count.get() + 1));
15780        }
15781    }
15782
15783    #[test]
15784    fn nonrepairing_open_policy_leaves_moved_root_metadata_for_maintenance() {
15785        let dir = tempdir().unwrap();
15786        let previous_root = dir.path().join("previous-root");
15787        let current_root = dir.path().join("current-root");
15788        fs::create_dir_all(&previous_root).unwrap();
15789        fs::create_dir_all(&current_root).unwrap();
15790        fs::remove_dir(&previous_root).unwrap();
15791        let mut conn = Connection::open_in_memory().unwrap();
15792        initialize_schema(&conn).unwrap();
15793        conn.execute(
15794            "INSERT INTO backend_file_state(
15795                backend, workspace_root, file_path, content_hash, status, updated_at
15796             ) VALUES ('rust', ?1, 'src/main.rs', 'hash', 'ready', 1)",
15797            params![previous_root.display().to_string()],
15798        )
15799        .unwrap();
15800
15801        let repair = reconcile_workspace_roots(&mut conn, &current_root, false).unwrap();
15802
15803        assert!(matches!(repair, OpenRootRepair::NeedsRebuild { .. }));
15804        assert_eq!(
15805            stored_workspace_roots(&conn).unwrap(),
15806            vec![previous_root.display().to_string()]
15807        );
15808    }
15809
15810    #[test]
15811    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
15812        assert_eq!(
15813            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
15814            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
15815        );
15816    }
15817
15818    #[test]
15819    fn legacy_migration_completion_log_has_operator_fields() {
15820        assert_eq!(
15821            legacy_migration_completion_line("abc123", "generation_copy", 176, 177),
15822            "migrated root-keyed callgraph store key=abc123 method=generation_copy legacy=176 migrated=177"
15823        );
15824    }
15825
15826    fn write_generation_with_age(
15827        dir: &Path,
15828        project_key: &str,
15829        ordinal: u64,
15830        age: Duration,
15831    ) -> String {
15832        let generation = format!("{project_key}.g{ordinal}.1.sqlite");
15833        let path = dir.join(&generation);
15834        fs::write(&path, b"sqlite placeholder").unwrap();
15835        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
15836        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
15837        generation
15838    }
15839
15840    #[test]
15841    fn gc_old_generations_preserves_live_reader_until_marker_drops() {
15842        let dir = tempfile::tempdir().unwrap();
15843        let project_key = "project";
15844        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15845        let previous =
15846            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15847        let pinned =
15848            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15849        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15850
15851        gc_old_generations(dir.path(), project_key, &current);
15852
15853        assert!(dir.path().join(&previous).is_file());
15854        assert!(dir.path().join(&pinned).is_file());
15855
15856        drop(marker);
15857        gc_old_generations(dir.path(), project_key, &current);
15858
15859        assert!(dir.path().join(&previous).is_file());
15860        assert!(!dir.path().join(&pinned).exists());
15861    }
15862
15863    #[test]
15864    fn gc_old_generations_ignores_same_host_marker_mtime_for_live_pid() {
15865        let dir = tempfile::tempdir().unwrap();
15866        let project_key = "project";
15867        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15868        let _previous =
15869            write_generation_with_age(dir.path(), project_key, 300, Duration::from_secs(1));
15870        let pinned =
15871            write_generation_with_age(dir.path(), project_key, 200, Duration::from_secs(2));
15872        let marker = crate::root_cache::ReadMarker::create(dir.path(), &pinned).unwrap();
15873        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
15874
15875        gc_old_generations(dir.path(), project_key, &current);
15876
15877        assert!(dir.path().join(&pinned).is_file());
15878    }
15879
15880    #[test]
15881    fn gc_old_generations_applies_retention_ttl_to_marked_old_generations() {
15882        let dir = tempfile::tempdir().unwrap();
15883        let project_key = "project";
15884        let expired = MARKED_GENERATION_RETENTION_TTL + Duration::from_secs(60);
15885        let current = write_generation_with_age(dir.path(), project_key, 400, Duration::ZERO);
15886        let previous = write_generation_with_age(dir.path(), project_key, 300, expired);
15887        let old = write_generation_with_age(
15888            dir.path(),
15889            project_key,
15890            200,
15891            expired + Duration::from_secs(60),
15892        );
15893        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &old).unwrap();
15894
15895        gc_old_generations(dir.path(), project_key, &current);
15896
15897        assert!(dir.path().join(&current).is_file());
15898        assert!(dir.path().join(&previous).is_file());
15899        assert!(!dir.path().join(&old).exists());
15900    }
15901
15902    fn write_aged_callgraph_root(callgraph_root: &Path, key: &str) -> PathBuf {
15903        let cache_dir = callgraph_root.join(key);
15904        fs::create_dir_all(cache_dir.join("nested")).unwrap();
15905        fs::write(
15906            cache_dir.join("nested").join("payload.sqlite"),
15907            b"old cache payload",
15908        )
15909        .unwrap();
15910        age_callgraph_root_tree(&cache_dir);
15911        cache_dir
15912    }
15913
15914    fn age_callgraph_root_tree(path: &Path) {
15915        let old = SystemTime::now()
15916            .checked_sub(CALLGRAPH_ROOT_ORPHAN_MIN_AGE + Duration::from_secs(60))
15917            .unwrap_or(UNIX_EPOCH);
15918        let entries = fs::read_dir(path)
15919            .unwrap()
15920            .collect::<std::io::Result<Vec<_>>>()
15921            .unwrap();
15922        for entry in entries {
15923            let child = entry.path();
15924            if entry.file_type().unwrap().is_dir() {
15925                age_callgraph_root_tree(&child);
15926            } else {
15927                filetime::set_file_mtime(&child, filetime::FileTime::from_system_time(old))
15928                    .unwrap();
15929            }
15930        }
15931        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(old)).unwrap();
15932    }
15933
15934    #[test]
15935    fn callgraph_root_sweep_reaps_only_aged_unprotected_dead_roots() {
15936        reset_callgraph_root_sweep_cursor_for_test();
15937        let storage = tempdir().unwrap();
15938        let callgraph_root = storage.path().join("callgraph");
15939        let dead = write_aged_callgraph_root(&callgraph_root, "f1e2d3c4b5a69788");
15940        let leased = write_aged_callgraph_root(&callgraph_root, "e1d2c3b4a5968778");
15941        let fresh = callgraph_root.join("d1c2b3a495867768");
15942        fs::create_dir_all(&fresh).unwrap();
15943        fs::write(fresh.join("payload.sqlite"), b"fresh cache payload").unwrap();
15944        let marked = write_aged_callgraph_root(&callgraph_root, "c1b2a39485766758");
15945
15946        let writer_lease = crate::fs_lock::try_acquire(
15947            &crate::root_cache::writer_lease_path(&leased),
15948            Duration::ZERO,
15949        )
15950        .unwrap();
15951        age_callgraph_root_tree(&leased);
15952        let marker = crate::root_cache::ReadMarker::create(&marked, "generation").unwrap();
15953        // Same-host marker protection is PID-authoritative, so this old mtime
15954        // proves the reader guard instead of accidentally relying on freshness.
15955        age_callgraph_root_tree(&marked);
15956
15957        let first = sweep_callgraph_root_dirs_with_limits(
15958            &callgraph_root,
15959            &HashSet::new(),
15960            &HashSet::new(),
15961            CALLGRAPH_ROOT_SWEEP_BUDGET,
15962            usize::MAX,
15963        );
15964
15965        assert_eq!(first.removed, 1);
15966        assert!(first.bytes > 0, "the reaped byte count must be reported");
15967        assert!(!dead.exists(), "an aged dead root must be reaped");
15968        assert_eq!(first.skipped_lease, 1, "a held writer lease must win");
15969        assert_eq!(first.skipped_reader, 1, "a live reader marker must win");
15970        assert_eq!(first.skipped_fresh, 1, "a recent root must win");
15971        assert!(leased.is_dir(), "the leased root must survive");
15972        assert!(marked.is_dir(), "the reader-marked root must survive");
15973        assert!(fresh.is_dir(), "the recent root must survive");
15974
15975        drop(writer_lease);
15976        drop(marker);
15977        // Mutation controls: removing each guard and aging each payload makes
15978        // every initially protected decoy eligible for the next pass.
15979        for cache_dir in [&leased, &marked, &fresh] {
15980            age_callgraph_root_tree(cache_dir);
15981        }
15982        let second = sweep_callgraph_root_dirs_with_limits(
15983            &callgraph_root,
15984            &HashSet::new(),
15985            &HashSet::new(),
15986            CALLGRAPH_ROOT_SWEEP_BUDGET,
15987            usize::MAX,
15988        );
15989
15990        assert_eq!(second.removed, 3);
15991        for cache_dir in [&leased, &marked, &fresh] {
15992            assert!(
15993                !cache_dir.exists(),
15994                "the decoy must be reaped after its guard or freshness changes"
15995            );
15996        }
15997        reset_callgraph_root_sweep_cursor_for_test();
15998    }
15999
16000    #[test]
16001    fn callgraph_root_sweep_resumes_after_entry_budget() {
16002        reset_callgraph_root_sweep_cursor_for_test();
16003        let storage = tempdir().unwrap();
16004        let callgraph_root = storage.path().join("callgraph");
16005        let first = write_aged_callgraph_root(&callgraph_root, "1111111111111111");
16006        let second = write_aged_callgraph_root(&callgraph_root, "2222222222222222");
16007        let third = write_aged_callgraph_root(&callgraph_root, "3333333333333333");
16008
16009        let first_pass = sweep_callgraph_root_dirs_with_limits(
16010            &callgraph_root,
16011            &HashSet::new(),
16012            &HashSet::new(),
16013            CALLGRAPH_ROOT_SWEEP_BUDGET,
16014            1,
16015        );
16016        assert!(first_pass.budget_exhausted);
16017        assert_eq!(first_pass.scanned, 1);
16018        assert!(!first.exists());
16019        assert!(second.exists());
16020        assert!(third.exists());
16021
16022        let second_pass = sweep_callgraph_root_dirs_with_limits(
16023            &callgraph_root,
16024            &HashSet::new(),
16025            &HashSet::new(),
16026            CALLGRAPH_ROOT_SWEEP_BUDGET,
16027            1,
16028        );
16029        assert!(second_pass.budget_exhausted);
16030        assert!(!second.exists());
16031        assert!(third.exists());
16032
16033        let third_pass = sweep_callgraph_root_dirs_with_limits(
16034            &callgraph_root,
16035            &HashSet::new(),
16036            &HashSet::new(),
16037            CALLGRAPH_ROOT_SWEEP_BUDGET,
16038            1,
16039        );
16040        assert!(!third_pass.budget_exhausted);
16041        assert!(!third.exists());
16042        reset_callgraph_root_sweep_cursor_for_test();
16043    }
16044
16045    #[test]
16046    fn callgraph_root_sweep_runs_generation_gc_for_memoized_root() {
16047        reset_callgraph_root_sweep_cursor_for_test();
16048        let storage = tempdir().unwrap();
16049        let callgraph_root = storage.path().join("callgraph");
16050        let key = "a1b2c3d4e5f60718";
16051        let cache_dir = callgraph_root.join(key);
16052        fs::create_dir_all(&cache_dir).unwrap();
16053        let current = write_generation_with_age(&cache_dir, key, 400, Duration::ZERO);
16054        let previous = write_generation_with_age(&cache_dir, key, 300, Duration::from_secs(1));
16055        let obsolete = write_generation_with_age(&cache_dir, key, 200, Duration::from_secs(2));
16056        publish_pointer(&cache_dir, key, &current).unwrap();
16057        age_callgraph_root_tree(&cache_dir);
16058        let memo_keys = HashSet::from([key.to_string()]);
16059
16060        let summary = sweep_callgraph_root_dirs_with_limits(
16061            &callgraph_root,
16062            &memo_keys,
16063            &HashSet::new(),
16064            CALLGRAPH_ROOT_SWEEP_BUDGET,
16065            usize::MAX,
16066        );
16067
16068        assert_eq!(summary.generation_gc, 1);
16069        assert!(cache_dir.join(&current).is_file());
16070        assert!(cache_dir.join(&previous).is_file());
16071        assert!(
16072            !cache_dir.join(&obsolete).exists(),
16073            "the store-wide sweep must collect an inactive live root's obsolete generation"
16074        );
16075        reset_callgraph_root_sweep_cursor_for_test();
16076    }
16077
16078    fn write_build_temp_with_age(dir: &Path, name: &str, age: Duration) -> PathBuf {
16079        let path = dir.join(name);
16080        fs::write(&path, b"temp placeholder").unwrap();
16081        let mtime = SystemTime::now().checked_sub(age).unwrap_or(UNIX_EPOCH);
16082        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)).unwrap();
16083        path
16084    }
16085
16086    #[test]
16087    fn orphan_temp_sweep_removes_aged_orphan_and_journal_but_spares_fresh() {
16088        let dir = tempdir().unwrap();
16089        // One directory holds both an aged orphan (with its journal sidecar) and a
16090        // fresh temporary, so this proves the sweep SELECTS by age rather than
16091        // deleting everything in the directory.
16092        let aged = "project.g100.1.sqlite.tmp.1.200";
16093        let aged_journal = "project.g100.1.sqlite.tmp.1.200-journal";
16094        let fresh = "project.g300.1.sqlite.tmp.1.400";
16095        let aged_age = ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60);
16096        write_build_temp_with_age(dir.path(), aged, aged_age);
16097        write_build_temp_with_age(dir.path(), aged_journal, aged_age);
16098        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16099
16100        sweep_orphaned_build_temps(dir.path());
16101
16102        assert!(
16103            !dir.path().join(aged).exists(),
16104            "aged orphan must be removed"
16105        );
16106        assert!(
16107            !dir.path().join(aged_journal).exists(),
16108            "aged journal sidecar must be removed"
16109        );
16110        assert!(
16111            dir.path().join(fresh).is_file(),
16112            "fresh temporary must survive"
16113        );
16114    }
16115
16116    #[test]
16117    fn orphan_temp_sweep_reaches_legacy_store_for_root_with_no_pointer_or_build() {
16118        let storage = tempdir().unwrap();
16119        let storage_root = storage.path();
16120        // The production shape: a legacy per-harness store whose root no longer
16121        // builds there — no `.current` pointer, no running build — so the per-root
16122        // cleanup never fires for it. A sibling root still building in the
16123        // root-keyed store triggers the store-wide sweep, which must reach into the
16124        // legacy directory and reclaim the orphan.
16125        let legacy_dir = storage_root.join("opencode").join("callgraph");
16126        fs::create_dir_all(&legacy_dir).unwrap();
16127        let orphan = "deadbeef.g100.1.sqlite.tmp.1.200";
16128        write_build_temp_with_age(
16129            &legacy_dir,
16130            orphan,
16131            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16132        );
16133        assert!(
16134            !legacy_dir.join("deadbeef.current").exists(),
16135            "the dead root has no current pointer"
16136        );
16137
16138        let root_keyed_dir = storage_root.join("callgraph").join("livekey");
16139        fs::create_dir_all(&root_keyed_dir).unwrap();
16140
16141        sweep_orphaned_build_temps_store_wide(&root_keyed_dir);
16142
16143        assert!(
16144            !legacy_dir.join(orphan).exists(),
16145            "legacy orphan must be reclaimed by the store-wide sweep"
16146        );
16147    }
16148
16149    #[test]
16150    fn orphan_temp_sweep_negative_control_age_predicate_is_what_spares_fresh() {
16151        // NEGATIVE CONTROL, mutation-proved: forcing the age predicate to accept
16152        // everything (min_age = 0) removes the fresh temporary that the real 24h
16153        // threshold spares in the test above. If a mutation to the age check leaves
16154        // the fresh file in place here, the predicate is no longer doing the
16155        // selection work the fresh-survives assertion relies on.
16156        let dir = tempdir().unwrap();
16157        let fresh = "project.g300.1.sqlite.tmp.1.400";
16158        write_build_temp_with_age(dir.path(), fresh, Duration::ZERO);
16159
16160        sweep_orphaned_build_temps_older_than(dir.path(), Duration::ZERO);
16161
16162        assert!(
16163            !dir.path().join(fresh).exists(),
16164            "with the age predicate forced open, the fresh temporary is removed"
16165        );
16166    }
16167
16168    #[test]
16169    fn orphan_temp_sweep_leaves_completed_generation_and_read_marker_alone() {
16170        let dir = tempdir().unwrap();
16171        // A completed generation (its name has no `.sqlite.tmp.`) that is old enough
16172        // to be swept, plus a live read marker, is generation GC's jurisdiction.
16173        // The orphan sweep must not intersect it.
16174        let generation = write_generation_with_age(
16175            dir.path(),
16176            "project",
16177            400,
16178            ORPHANED_BUILD_TEMP_MIN_AGE + Duration::from_secs(60),
16179        );
16180        let _marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16181
16182        sweep_orphaned_build_temps(dir.path());
16183
16184        assert!(
16185            dir.path().join(&generation).is_file(),
16186            "completed generation must survive the orphan sweep"
16187        );
16188        assert!(
16189            crate::root_cache::read_marker_dir(dir.path(), &generation).exists(),
16190            "read marker must survive the orphan sweep"
16191        );
16192    }
16193
16194    #[test]
16195    fn atomic_swap_checkpoint_uses_passive_when_live_marker_exists() {
16196        let dir = tempfile::tempdir().unwrap();
16197        let project_key = "project".to_string();
16198        let generation = write_generation_with_age(dir.path(), &project_key, 100, Duration::ZERO);
16199        let sqlite_path = dir.path().join(&generation);
16200        fs::remove_file(&sqlite_path).unwrap();
16201        let conn = TrackedConnection::open(&sqlite_path, SqliteStore::CallgraphGeneration).unwrap();
16202        let store = CallGraphStore::from_connection(
16203            dir.path().to_path_buf(),
16204            project_key,
16205            sqlite_path,
16206            dir.path().to_path_buf(),
16207            false,
16208            Some(generation.clone()),
16209            None,
16210            None,
16211            conn,
16212        );
16213
16214        let marker = crate::root_cache::ReadMarker::create(dir.path(), &generation).unwrap();
16215        assert!(store.atomic_swap_checkpoint_sql().contains("PASSIVE"));
16216
16217        drop(marker);
16218        assert!(store.atomic_swap_checkpoint_sql().contains("TRUNCATE"));
16219    }
16220
16221    #[test]
16222    fn readiness_cache_only_skips_checks_after_a_successful_validation() {
16223        let dir = tempdir().expect("temp dir");
16224        let file = dir.path().join("main.ts");
16225        fs::write(&file, "export function main() {}\n").expect("write fixture");
16226        let store = CallGraphStore::open(
16227            dir.path().join(".store-readiness-cache"),
16228            dir.path().to_path_buf(),
16229        )
16230        .expect("open store");
16231        {
16232            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16233            conn.trace(Some(count_caller_traversal_selects));
16234        }
16235
16236        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16237        assert!(store.indexed_file_count().is_err());
16238        assert!(store.indexed_file_count().is_err());
16239        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16240
16241        store
16242            .cold_build(std::slice::from_ref(&file))
16243            .expect("cold build");
16244        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16245        assert_eq!(store.indexed_file_count().expect("first ready read"), 1);
16246        assert_eq!(store.indexed_file_count().expect("cached ready read"), 1);
16247        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 5);
16248
16249        let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16250        conn.trace(None);
16251    }
16252
16253    #[test]
16254    fn direct_caller_frontier_chunks_sqlite_selects() {
16255        let dir = tempdir().expect("temp dir");
16256        let file = dir.path().join("main.ts");
16257        fs::write(
16258            &file,
16259            "export function caller() { target(); }\nexport function target() {}\n",
16260        )
16261        .expect("write fixture");
16262        let store = CallGraphStore::open(
16263            dir.path().join(".store-caller-frontier-query"),
16264            dir.path().to_path_buf(),
16265        )
16266        .expect("open store");
16267        store
16268            .cold_build(std::slice::from_ref(&file))
16269            .expect("cold build");
16270        let mut targets = vec![("main.ts".to_string(), "target".to_string())];
16271        targets.extend((1..1_000).map(|index| ("main.ts".to_string(), format!("missing{index}"))));
16272
16273        CALLER_QUERY_SELECTS.with(|count| count.set(0));
16274        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16275        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16276        {
16277            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16278            conn.trace(Some(count_caller_traversal_selects));
16279        }
16280        let callers = store
16281            .direct_callers_for_symbols(&targets)
16282            .expect("batched callers");
16283        {
16284            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16285            conn.trace(None);
16286        }
16287
16288        assert_eq!(callers.len(), 1_000);
16289        assert_eq!(callers.get(&targets[0]).unwrap().len(), 1);
16290        assert_eq!(CALLER_QUERY_SELECTS.with(Cell::get), 3);
16291        assert_eq!(BOUNDARY_COUNT_SELECTS.with(Cell::get), 0);
16292        assert_eq!(TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get), 6);
16293    }
16294
16295    #[test]
16296    fn callers_depth_boundary_batches_sqlite_counts() {
16297        const CALLER_COUNT: usize = 1_000;
16298
16299        let dir = tempdir().expect("temp dir");
16300        let file = dir.path().join("main.ts");
16301        let mut source = String::from("export function sharedHotHelper() {}\n");
16302        for index in 0..CALLER_COUNT {
16303            source.push_str(&format!(
16304                "export function caller{index}() {{ sharedHotHelper(); }}\n"
16305            ));
16306        }
16307        fs::write(&file, source).expect("write fixture");
16308
16309        let store = CallGraphStore::open(
16310            dir.path().join(".store-callers-query-fanout"),
16311            dir.path().to_path_buf(),
16312        )
16313        .expect("open store");
16314        store
16315            .cold_build(std::slice::from_ref(&file))
16316            .expect("cold build");
16317
16318        CALLER_QUERY_SELECTS.with(|count| count.set(0));
16319        BOUNDARY_COUNT_SELECTS.with(|count| count.set(0));
16320        TOTAL_CALLER_TRAVERSAL_SELECTS.with(|count| count.set(0));
16321        {
16322            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16323            conn.trace(Some(count_caller_traversal_selects));
16324        }
16325
16326        let started = Instant::now();
16327        let result = crate::commands::callgraph_store_adapter::callers_result(
16328            &store,
16329            Path::new("main.ts"),
16330            "sharedHotHelper",
16331            1,
16332            true,
16333        )
16334        .expect("callers result");
16335        let elapsed = started.elapsed();
16336
16337        {
16338            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
16339            conn.trace(None);
16340        }
16341        let caller_queries = CALLER_QUERY_SELECTS.with(Cell::get);
16342        let boundary_queries = BOUNDARY_COUNT_SELECTS.with(Cell::get);
16343        let total_selects = TOTAL_CALLER_TRAVERSAL_SELECTS.with(Cell::get);
16344        eprintln!(
16345            "SQLITE_CALLERS_AFTER callers={} caller_queries={} boundary_queries={} total_selects={} elapsed_ms={:.3}",
16346            result.total_callers,
16347            caller_queries,
16348            boundary_queries,
16349            total_selects,
16350            elapsed.as_secs_f64() * 1_000.0
16351        );
16352
16353        assert_eq!(result.total_callers, CALLER_COUNT);
16354        assert_eq!(caller_queries, 1);
16355        assert_eq!(boundary_queries, 3);
16356        assert_eq!(total_selects, 9);
16357    }
16358
16359    #[test]
16360    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
16361        let dir = tempdir().expect("temp dir");
16362        let file = dir.path().join("main.ts");
16363        fs::write(
16364            &file,
16365            r#"export function topA() {
16366  root();
16367}
16368
16369export function topB() {
16370  root();
16371}
16372
16373export function root() {
16374  leaf();
16375  missing();
16376}
16377
16378export function leaf() {}
16379"#,
16380        )
16381        .expect("write fixture");
16382
16383        let store = CallGraphStore::open(
16384            dir.path().join(".store-depth-boundary-counts"),
16385            dir.path().to_path_buf(),
16386        )
16387        .expect("open store");
16388        store
16389            .cold_build(std::slice::from_ref(&file))
16390            .expect("cold build");
16391
16392        let root = store
16393            .node_for(Path::new("main.ts"), "root")
16394            .expect("root node");
16395        let leaf = store
16396            .node_for(Path::new("main.ts"), "leaf")
16397            .expect("leaf node");
16398
16399        let (full_forward_len, full_direct_len) = {
16400            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
16401            conn.execute(
16402                "INSERT INTO edges (
16403                    edge_id, ref_id, source_node, target_node, target_file,
16404                    target_symbol, kind, line, provenance
16405                 ) VALUES (
16406                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
16407                    ?2, ?3, 'call', 98, ?4
16408                 )",
16409                rusqlite::params![
16410                    &root.node_id,
16411                    &leaf.file,
16412                    &leaf.symbol,
16413                    PROVENANCE_TREESITTER
16414                ],
16415            )
16416            .expect("insert dangling forward edge");
16417            conn.execute(
16418                "INSERT INTO edges (
16419                    edge_id, ref_id, source_node, target_node, target_file,
16420                    target_symbol, kind, line, provenance
16421                 ) VALUES (
16422                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
16423                    ?1, ?2, ?3, 'call', 99, ?4
16424                 )",
16425                rusqlite::params![
16426                    &root.node_id,
16427                    &root.file,
16428                    &root.symbol,
16429                    PROVENANCE_TREESITTER
16430                ],
16431            )
16432            .expect("insert dangling direct-caller edge");
16433
16434            let full_forward_len = forward_calls_for_node(&conn, &root)
16435                .expect("full forward calls")
16436                .len();
16437            let counted_forward_len =
16438                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
16439            assert_eq!(
16440                counted_forward_len, full_forward_len,
16441                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
16442            );
16443
16444            let full_direct = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
16445                .expect("full direct callers");
16446            let full_direct_len = full_direct.len();
16447            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
16448                .expect("counted direct callers");
16449            assert_eq!(
16450                counted_direct_len, full_direct_len,
16451                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
16452            );
16453
16454            let distinct_direct_len = full_direct
16455                .iter()
16456                .map(|site| {
16457                    (
16458                        site.caller.file.clone(),
16459                        site.line,
16460                        site.target_file.clone(),
16461                        site.target_symbol.clone(),
16462                    )
16463                })
16464                .collect::<BTreeSet<_>>()
16465                .len();
16466            let batch_counts = direct_caller_counts_for_tuples(
16467                &conn,
16468                &[
16469                    (root.file.clone(), root.symbol.clone()),
16470                    (root.file.clone(), root.symbol.clone()),
16471                    (leaf.file.clone(), leaf.symbol.clone()),
16472                ],
16473            )
16474            .expect("batched direct-caller counts");
16475            assert_eq!(batch_counts.len(), 2);
16476            assert_eq!(
16477                batch_counts.get(&(root.file.clone(), root.symbol.clone())),
16478                Some(&distinct_direct_len)
16479            );
16480
16481            (full_forward_len, full_direct_len)
16482        };
16483
16484        assert_eq!(
16485            full_forward_len, 2,
16486            "fixture root should have one resolved and one unresolved outgoing call"
16487        );
16488        assert_eq!(
16489            full_direct_len, 2,
16490            "fixture root should have two real direct callers"
16491        );
16492
16493        let tree = store
16494            .call_tree(Path::new("main.ts"), "root", 0)
16495            .expect("call tree");
16496        assert!(tree.depth_limited);
16497        assert_eq!(tree.children.len(), 0);
16498        assert_eq!(
16499            tree.truncated, full_forward_len,
16500            "call_tree depth boundary must report the full forward-call list length"
16501        );
16502
16503        let callers = store
16504            .callers_of(Path::new("main.ts"), "leaf", 0)
16505            .expect("callers");
16506        assert!(callers.depth_limited);
16507        assert_eq!(callers.callers.len(), 1);
16508        assert_eq!(callers.callers[0].caller.symbol, "root");
16509        assert_eq!(
16510            callers.truncated, full_direct_len,
16511            "callers depth boundary must report the full direct-caller list length"
16512        );
16513    }
16514
16515    #[test]
16516    fn source_freshness_matches_cache_collect_for_same_bytes() {
16517        let dir = tempdir().expect("temp dir");
16518        let path = dir.path().join("fixture.ts");
16519        let source = "export function main() { return helper(); }\n";
16520        fs::write(&path, source).expect("write fixture");
16521
16522        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
16523        let actual =
16524            collect_source_freshness(&path, source).expect("collect freshness from source");
16525
16526        assert_eq!(actual, expected);
16527    }
16528
16529    #[test]
16530    fn superseded_cold_build_cannot_publish_after_newer_epoch() {
16531        let root = tempfile::tempdir().unwrap();
16532        let callgraph_dir = tempfile::tempdir().unwrap();
16533        let source_dir = root.path().join("src");
16534        std::fs::create_dir_all(&source_dir).unwrap();
16535        let source = source_dir.join("lib.rs");
16536        std::fs::write(&source, "pub fn old_generation_marker() {}\n").unwrap();
16537        let files = vec![source.clone()];
16538        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16539        let old_epoch = epoch.next();
16540        let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
16541        let (release_tx, release_rx) = crossbeam_channel::bounded(1);
16542        let old_epoch_flag = epoch.clone();
16543        let old_dir = callgraph_dir.path().to_path_buf();
16544        let old_root = root.path().to_path_buf();
16545        let old_files = files.clone();
16546        let old = std::thread::spawn(move || {
16547            set_cold_build_before_publish_observer(Some(Arc::new(move || {
16548                reached_tx.send(()).unwrap();
16549                release_rx.recv().unwrap();
16550            })));
16551            let result = with_publish_epoch(old_epoch_flag, old_epoch, || {
16552                CallGraphStore::cold_build_with_lease(old_dir, old_root, &old_files)
16553            });
16554            set_cold_build_before_publish_observer(None);
16555            result
16556        });
16557        // Positive wait: the older build runs a real cold build (git probe +
16558        // SQLite schema init) before the barrier, which can exceed 5s on a
16559        // contended Windows CI runner. Only negative waits stay short.
16560        reached_rx
16561            .recv_timeout(Duration::from_secs(30))
16562            .expect("older build did not reach its publication barrier");
16563
16564        std::fs::write(&source, "pub fn new_generation_marker() {}\n").unwrap();
16565        let new_epoch = epoch.next();
16566        let new_store = with_publish_epoch(epoch.clone(), new_epoch, || {
16567            CallGraphStore::cold_build_with_lease(
16568                callgraph_dir.path().to_path_buf(),
16569                root.path().to_path_buf(),
16570                &files,
16571            )
16572        })
16573        .expect("newer build should publish");
16574        drop(new_store);
16575
16576        release_tx.send(()).unwrap();
16577        assert!(matches!(
16578            old.join().unwrap(),
16579            Err(CallGraphStoreError::Superseded)
16580        ));
16581
16582        let current = CallGraphStore::open_readonly(
16583            callgraph_dir.path().to_path_buf(),
16584            root.path().to_path_buf(),
16585        )
16586        .unwrap()
16587        .expect("current callgraph generation");
16588        assert_eq!(
16589            current
16590                .nodes_matching("new_generation_marker")
16591                .unwrap()
16592                .len(),
16593            1
16594        );
16595        assert!(current
16596            .nodes_matching("old_generation_marker")
16597            .unwrap()
16598            .is_empty());
16599    }
16600
16601    #[test]
16602    fn publish_fence_supersession_keeps_completed_staging_for_zero_work_adoption() {
16603        let root = tempfile::tempdir().unwrap();
16604        let callgraph_dir = tempfile::tempdir().unwrap();
16605        let source = root.path().join("lib.rs");
16606        std::fs::write(&source, "pub fn completed_marker() {}\n").unwrap();
16607        let files = vec![source];
16608        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16609        let old_epoch = epoch.next();
16610        let epoch_for_observer = epoch.clone();
16611        set_cold_build_before_publish_observer(Some(Arc::new(move || {
16612            epoch_for_observer.next();
16613        })));
16614        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16615            CallGraphStore::cold_build_with_lease_chunked(
16616                callgraph_dir.path().to_path_buf(),
16617                root.path().to_path_buf(),
16618                &files,
16619                1,
16620            )
16621        });
16622        set_cold_build_before_publish_observer(None);
16623        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16624
16625        let project_key = crate::search_index::artifact_cache_key(root.path());
16626        let staging = callgraph_dir
16627            .path()
16628            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16629        let staged = Connection::open(&staging).unwrap();
16630        assert_eq!(
16631            staged_build_phase(&staged).unwrap().as_deref(),
16632            Some("ready")
16633        );
16634        drop(staged);
16635
16636        let extracted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16637        let extracted_for_observer = Arc::clone(&extracted);
16638        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16639            extracted_for_observer.fetch_add(paths.len(), AtomicOrdering::SeqCst);
16640        })));
16641        let successor_epoch = epoch.next();
16642        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16643            CallGraphStore::cold_build_with_lease_chunked(
16644                callgraph_dir.path().to_path_buf(),
16645                root.path().to_path_buf(),
16646                &files,
16647                1,
16648            )
16649        })
16650        .expect("completed same-corpus staging publishes without rebuilding");
16651        set_cold_build_extract_observer(None);
16652
16653        assert_eq!(stats.files, 1);
16654        assert_eq!(
16655            extracted.load(AtomicOrdering::SeqCst),
16656            0,
16657            "completed staging must not repeat extraction"
16658        );
16659        drop(store);
16660    }
16661
16662    #[test]
16663    fn superseded_slice_preserves_staging_and_same_corpus_successor_resumes() {
16664        let root = tempfile::tempdir().unwrap();
16665        let callgraph_dir = tempfile::tempdir().unwrap();
16666        let files = ["a.rs", "b.rs", "c.rs"]
16667            .into_iter()
16668            .map(|name| {
16669                let path = root.path().join(name);
16670                std::fs::write(&path, format!("pub fn {}() {{}}\n", name.replace('.', "_")))
16671                    .unwrap();
16672                path
16673            })
16674            .collect::<Vec<_>>();
16675        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16676        let old_epoch = epoch.next();
16677        let superseded = Arc::new(AtomicBool::new(false));
16678        let epoch_for_observer = epoch.clone();
16679        let superseded_for_observer = Arc::clone(&superseded);
16680        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16681            if stage == "extraction"
16682                && completed == 1
16683                && !superseded_for_observer.swap(true, AtomicOrdering::SeqCst)
16684            {
16685                epoch_for_observer.next();
16686            }
16687        })));
16688
16689        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16690            CallGraphStore::cold_build_with_lease_chunked(
16691                callgraph_dir.path().to_path_buf(),
16692                root.path().to_path_buf(),
16693                &files,
16694                1,
16695            )
16696        });
16697        set_cold_build_slice_observer(None);
16698        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16699        assert!(superseded.load(AtomicOrdering::SeqCst));
16700
16701        let project_key = crate::search_index::artifact_cache_key(root.path());
16702        let staging = callgraph_dir
16703            .path()
16704            .join(format!("{project_key}.staging.sqlite.tmp.resume"));
16705        assert!(staging.exists(), "supersession must retain durable staging");
16706        let staged = Connection::open(&staging).unwrap();
16707        assert_eq!(
16708            staged_build_phase(&staged).unwrap().as_deref(),
16709            Some("extracting")
16710        );
16711        assert_eq!(
16712            query_count(&staged, "SELECT COUNT(*) FROM files").unwrap(),
16713            1
16714        );
16715        drop(staged);
16716
16717        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16718        let extracted_for_observer = Arc::clone(&extracted);
16719        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16720            extracted_for_observer
16721                .lock()
16722                .unwrap()
16723                .extend(paths.iter().filter_map(|path| {
16724                    path.file_name()
16725                        .map(|name| name.to_string_lossy().into_owned())
16726                }));
16727        })));
16728        let successor_epoch = epoch.next();
16729        let (store, stats) = with_publish_epoch(epoch.clone(), successor_epoch, || {
16730            CallGraphStore::cold_build_with_lease_chunked(
16731                callgraph_dir.path().to_path_buf(),
16732                root.path().to_path_buf(),
16733                &files,
16734                1,
16735            )
16736        })
16737        .expect("same-corpus successor resumes and publishes");
16738        set_cold_build_extract_observer(None);
16739
16740        assert_eq!(stats.files, 3);
16741        assert_eq!(
16742            *extracted.lock().unwrap(),
16743            vec!["b.rs".to_string(), "c.rs".to_string()],
16744            "the successor must not repeat the committed first slice"
16745        );
16746        drop(store);
16747        assert!(
16748            !staging.exists(),
16749            "published staging moves to its generation"
16750        );
16751    }
16752
16753    #[test]
16754    fn changed_corpus_restarts_instead_of_adopting_staged_progress() {
16755        let root = tempfile::tempdir().unwrap();
16756        let callgraph_dir = tempfile::tempdir().unwrap();
16757        let first = root.path().join("a.rs");
16758        let second = root.path().join("b.rs");
16759        std::fs::write(&first, "pub fn a() {}\n").unwrap();
16760        std::fs::write(&second, "pub fn b() {}\n").unwrap();
16761        let mut files = vec![first.clone(), second.clone()];
16762        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
16763        let old_epoch = epoch.next();
16764        let advanced = Arc::new(AtomicBool::new(false));
16765        let epoch_for_observer = epoch.clone();
16766        let advanced_for_observer = Arc::clone(&advanced);
16767        set_cold_build_slice_observer(Some(Arc::new(move |stage, completed, _total| {
16768            if stage == "extraction"
16769                && completed == 1
16770                && !advanced_for_observer.swap(true, AtomicOrdering::SeqCst)
16771            {
16772                epoch_for_observer.next();
16773            }
16774        })));
16775        let result = with_publish_epoch(epoch.clone(), old_epoch, || {
16776            CallGraphStore::cold_build_with_lease_chunked(
16777                callgraph_dir.path().to_path_buf(),
16778                root.path().to_path_buf(),
16779                &files,
16780                1,
16781            )
16782        });
16783        set_cold_build_slice_observer(None);
16784        assert!(matches!(result, Err(CallGraphStoreError::Superseded)));
16785
16786        std::fs::write(&first, "pub fn a_changed() { b(); }\n").unwrap();
16787        let third = root.path().join("c.rs");
16788        std::fs::write(&third, "pub fn c() {}\n").unwrap();
16789        files.push(third);
16790        let extracted = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
16791        let extracted_for_observer = Arc::clone(&extracted);
16792        set_cold_build_extract_observer(Some(Arc::new(move |paths| {
16793            extracted_for_observer
16794                .lock()
16795                .unwrap()
16796                .extend(paths.iter().filter_map(|path| {
16797                    path.file_name()
16798                        .map(|name| name.to_string_lossy().into_owned())
16799                }));
16800        })));
16801        let successor_epoch = epoch.next();
16802        let (store, stats) = with_publish_epoch(epoch, successor_epoch, || {
16803            CallGraphStore::cold_build_with_lease_chunked(
16804                callgraph_dir.path().to_path_buf(),
16805                root.path().to_path_buf(),
16806                &files,
16807                1,
16808            )
16809        })
16810        .expect("changed-corpus successor restarts and publishes");
16811        set_cold_build_extract_observer(None);
16812
16813        assert_eq!(stats.files, 3);
16814        assert_eq!(
16815            *extracted.lock().unwrap(),
16816            vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()],
16817            "fingerprint mismatch must invalidate every old extraction slice"
16818        );
16819        drop(store);
16820    }
16821
16822    #[test]
16823    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
16824        let dir = tempdir().expect("temp dir");
16825        let project_root = dir.path();
16826        let extract = fixture_extract(project_root);
16827        let resolved = fixture_resolved(&extract);
16828
16829        let reference = build_reference_connection(project_root, &extract, &resolved);
16830        let optimized = build_optimized_connection(project_root, &extract, &resolved);
16831
16832        for table in [
16833            "files",
16834            "nodes",
16835            "file_dependencies",
16836            "dispatch_hints",
16837            "refs",
16838            "edges",
16839        ] {
16840            // `files.indexed_at` is a wall-clock insert timestamp (unix_seconds_now);
16841            // the reference and optimized builds run sequentially and can straddle a
16842            // one-second tick under load, so it is legitimately allowed to differ.
16843            // This mirrors the existing exclusions of `backend_file_state.updated_at`
16844            // and the chunked-vs-unchunked sibling test. The check is for structural
16845            // row equivalence of the optimized bulk insert, not wall-clock equality.
16846            let excluded: &[&str] = if table == "files" {
16847                &["indexed_at"]
16848            } else {
16849                &[]
16850            };
16851            assert_eq!(
16852                table_rows_without(&reference, table, excluded),
16853                table_rows_without(&optimized, table, excluded),
16854                "table `{table}` rows must match apart from wall-clock columns"
16855            );
16856        }
16857        assert_eq!(
16858            backend_state_rows(&reference),
16859            backend_state_rows(&optimized),
16860            "backend freshness rows must match apart from updated_at"
16861        );
16862        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
16863    }
16864
16865    #[test]
16866    fn cold_build_chunked_matches_unchunked_logical_rows() {
16867        let dir = tempdir().expect("temp dir");
16868        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
16869        write_chunked_equivalence_fixture(&project_root);
16870        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
16871        assert!(
16872            files.len() > 6,
16873            "fixture should be large enough to split into multiple chunks"
16874        );
16875
16876        let unchunked = CallGraphStore::open(
16877            project_root.join(".store-unchunked"),
16878            project_root.to_path_buf(),
16879        )
16880        .expect("open unchunked store");
16881        let unchunked_stats = unchunked
16882            .cold_build_chunked(&files, 0)
16883            .expect("unchunked cold build");
16884
16885        let chunked = CallGraphStore::open(
16886            project_root.join(".store-chunked"),
16887            project_root.to_path_buf(),
16888        )
16889        .expect("open chunked store");
16890        let chunked_stats = chunked
16891            .cold_build_chunked(&files, 3)
16892            .expect("chunked cold build");
16893
16894        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
16895        assert_eq!(
16896            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
16897            chunked.edge_snapshot().expect("chunked edge snapshot"),
16898            "public edge snapshots must match"
16899        );
16900
16901        let dispatch_edges = {
16902            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
16903            conn.query_row(
16904                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
16905                [],
16906                |row| row.get::<_, i64>(0),
16907            )
16908            .expect("count dispatch edges")
16909        };
16910        assert!(
16911            dispatch_edges > 0,
16912            "fixture must exercise method-dispatch edge insertion"
16913        );
16914
16915        for table in [
16916            "edges",
16917            "refs",
16918            "nodes",
16919            "file_dependencies",
16920            "dispatch_hints",
16921        ] {
16922            assert_eq!(
16923                graph_table_rows(&unchunked, table),
16924                graph_table_rows(&chunked, table),
16925                "chunked cold build must match unchunked rows for {table}"
16926            );
16927        }
16928        assert_eq!(
16929            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
16930            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
16931            "files rows must match apart from indexed_at"
16932        );
16933        assert_eq!(
16934            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
16935            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
16936            "backend freshness rows must match apart from updated_at"
16937        );
16938
16939        let published_dir = project_root.join(".store-published");
16940        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
16941            published_dir.clone(),
16942            project_root.to_path_buf(),
16943            &files,
16944            0,
16945        )
16946        .expect("published unchunked cold build");
16947        assert!(
16948            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
16949                .expect("needs_cold_build after publish"),
16950            "published store should be ready"
16951        );
16952        drop(_published);
16953        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
16954            published_dir,
16955            project_root.to_path_buf(),
16956            &files,
16957            3,
16958        )
16959        .expect("ensure with a different chunk size");
16960        assert!(
16961            rebuild_stats.is_none(),
16962            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
16963        );
16964    }
16965
16966    #[test]
16967    fn cold_build_resolution_memo_bounds_filesystem_probes_and_preserves_rows() {
16968        let dir = tempdir().expect("temp dir");
16969        let project_root = dir.path().join("project");
16970        fs::create_dir_all(&project_root).expect("create project root");
16971        let project_root = fs::canonicalize(project_root).expect("canonical project root");
16972        let files = write_ts_resolution_memo_fixture(&project_root, 8, 8, 4);
16973        let resolve_window = 19;
16974
16975        callgraph::clear_workspace_package_cache();
16976        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
16977        let uncached = CallGraphStore::open(
16978            dir.path().join("store-uncached"),
16979            project_root.to_path_buf(),
16980        )
16981        .expect("open uncached store");
16982        // Bypass the outer disk-index memo so this comparison still isolates
16983        // the filesystem-facing module-resolution memo.
16984        let uncached_stats = uncached
16985            .cold_build_chunked_with_disk_index_memo_for_test(
16986                &files,
16987                7,
16988                resolve_window,
16989                &uncached_memo,
16990                false,
16991            )
16992            .expect("uncached comparison build");
16993        assert!(
16994            uncached_stats.refs > resolve_window * 2,
16995            "fixture must cross several staged reference windows"
16996        );
16997
16998        callgraph::clear_workspace_package_cache();
16999        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17000        let cached =
17001            CallGraphStore::open(dir.path().join("store-cached"), project_root.to_path_buf())
17002                .expect("open cached store");
17003        let cached_stats = cached
17004            .cold_build_chunked_with_resolution_memo_for_test(
17005                &files,
17006                7,
17007                resolve_window,
17008                &cached_memo,
17009            )
17010            .expect("cached build");
17011
17012        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17013        for table in [
17014            "nodes",
17015            "refs",
17016            "file_dependencies",
17017            "edges",
17018            "dispatch_hints",
17019            "type_ref_names",
17020            "meta",
17021            "staging_file_inventory",
17022            "staging_ref_context",
17023        ] {
17024            assert_eq!(
17025                graph_table_rows(&uncached, table),
17026                graph_table_rows(&cached, table),
17027                "memoized and uncached cold builds must produce identical {table} rows"
17028            );
17029        }
17030        assert_eq!(
17031            graph_table_rows_without(&uncached, "files", &["indexed_at"]),
17032            graph_table_rows_without(&cached, "files", &["indexed_at"]),
17033            "files rows must match apart from indexed_at"
17034        );
17035        assert_eq!(
17036            graph_table_rows_without(&uncached, "backend_file_state", &["updated_at"]),
17037            graph_table_rows_without(&cached, "backend_file_state", &["updated_at"]),
17038            "backend rows must match apart from updated_at"
17039        );
17040
17041        let cached_module_computations = cached_memo.module_computations_for_test();
17042        assert!(
17043            !cached_module_computations.is_empty(),
17044            "fixture must exercise module resolution"
17045        );
17046        assert!(
17047            cached_module_computations.values().all(|count| *count == 1),
17048            "each importing-directory/specifier pair must reach the filesystem once"
17049        );
17050        let uncached_module_computations = uncached_memo.module_computations_for_test();
17051        assert!(
17052            uncached_module_computations
17053                .values()
17054                .copied()
17055                .max()
17056                .unwrap_or_default()
17057                > 16,
17058            "mutation control: disabling the memo must recompute a hot module target"
17059        );
17060
17061        let cached_package_probes = cached_memo
17062            .json_probes_for_test()
17063            .into_iter()
17064            .filter(|(path, _)| {
17065                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17066            })
17067            .collect::<HashMap<_, _>>();
17068        assert!(
17069            !cached_package_probes.is_empty(),
17070            "fixture must exercise package.json lookup"
17071        );
17072        assert!(
17073            cached_package_probes.values().all(|count| *count == 1),
17074            "every package.json path must be probed at most once per cold build"
17075        );
17076        let uncached_package_probes = uncached_memo
17077            .json_probes_for_test()
17078            .into_iter()
17079            .filter(|(path, _)| {
17080                path.file_name().and_then(|name| name.to_str()) == Some("package.json")
17081            })
17082            .collect::<HashMap<_, _>>();
17083        let cached_probe_total: usize = cached_package_probes.values().sum();
17084        let uncached_probe_total: usize = uncached_package_probes.values().sum();
17085        assert!(
17086            uncached_probe_total > cached_probe_total * 20,
17087            "mutation control: disabled memo should repeat the package ladder ({uncached_probe_total} vs {cached_probe_total})"
17088        );
17089    }
17090
17091    #[test]
17092    fn cold_build_disk_file_index_memo_preserves_resolved_rows() {
17093        let dir = tempdir().expect("temp dir");
17094        let project_root = dir.path().join("project");
17095        fs::create_dir_all(&project_root).expect("create project root");
17096        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17097        let files = write_rust_declared_module_memo_fixture(&project_root, 6);
17098
17099        let bypassed = CallGraphStore::open(
17100            dir.path().join("store-disk-memo-bypassed"),
17101            project_root.to_path_buf(),
17102        )
17103        .expect("open bypassed store");
17104        let bypassed_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17105        let bypassed_stats = bypassed
17106            .cold_build_chunked_with_disk_index_memo_for_test(
17107                &files,
17108                3,
17109                7,
17110                &bypassed_module_memo,
17111                false,
17112            )
17113            .expect("build with disk index memo bypassed");
17114
17115        let memoized = CallGraphStore::open(
17116            dir.path().join("store-disk-memoized"),
17117            project_root.to_path_buf(),
17118        )
17119        .expect("open memoized store");
17120        let memoized_module_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17121        let memoized_stats = memoized
17122            .cold_build_chunked_with_disk_index_memo_for_test(
17123                &files,
17124                3,
17125                7,
17126                &memoized_module_memo,
17127                true,
17128            )
17129            .expect("build with disk index memo enabled");
17130
17131        assert_cold_build_stats_match_except_elapsed(&bypassed_stats, &memoized_stats);
17132        for table in ["refs", "edges"] {
17133            assert_eq!(
17134                graph_table_rows(&bypassed, table),
17135                graph_table_rows(&memoized, table),
17136                "memoized and bypassed disk indexes must produce byte-identical {table} rows"
17137            );
17138        }
17139    }
17140
17141    #[test]
17142    fn rust_declared_module_memo_parses_each_declaring_file_once_and_preserves_edges() {
17143        let dir = tempdir().expect("temp dir");
17144        let project_root = dir.path().join("project");
17145        fs::create_dir_all(&project_root).expect("create project root");
17146        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17147        let files = write_rust_declared_module_memo_fixture(&project_root, 10);
17148
17149        let negative_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17150        for _ in 0..3 {
17151            assert_eq!(
17152                rust_declared_module_target(
17153                    &project_root,
17154                    "src/lib.rs",
17155                    "undeclared",
17156                    &negative_memo,
17157                    &FactPaths {
17158                        root: &project_root,
17159                        facts: &DiskFacts::new(&project_root)
17160                    },
17161                ),
17162                None
17163            );
17164        }
17165        assert_eq!(
17166            negative_memo
17167                .rust_declaration_parses_for_test()
17168                .get("src/lib.rs"),
17169            Some(&1),
17170            "an undeclared module must be retained as a file-level negative result"
17171        );
17172
17173        let uncached_memo = callgraph::ModuleResolutionMemo::new_for_test(false, true);
17174        let uncached = CallGraphStore::open(
17175            dir.path().join("rust-store-uncached"),
17176            project_root.to_path_buf(),
17177        )
17178        .expect("open uncached Rust store");
17179        // Bypass the outer disk-index memo so parse counts continue to measure
17180        // the Rust declaration memo rather than the higher-level cache.
17181        let uncached_stats = uncached
17182            .cold_build_chunked_with_disk_index_memo_for_test(&files, 3, 11, &uncached_memo, false)
17183            .expect("uncached Rust build");
17184
17185        let cached_memo = callgraph::ModuleResolutionMemo::new_for_test(true, true);
17186        let cached = CallGraphStore::open(
17187            dir.path().join("rust-store-cached"),
17188            project_root.to_path_buf(),
17189        )
17190        .expect("open cached Rust store");
17191        let cached_stats = cached
17192            .cold_build_chunked_with_resolution_memo_for_test(&files, 3, 11, &cached_memo)
17193            .expect("cached Rust build");
17194
17195        assert!(
17196            cached_stats.refs >= 60,
17197            "fixture must exercise repeated qualified and undeclared paths"
17198        );
17199        assert_cold_build_stats_match_except_elapsed(&uncached_stats, &cached_stats);
17200        assert_eq!(
17201            uncached.edge_snapshot().expect("uncached edge snapshot"),
17202            cached.edge_snapshot().expect("cached edge snapshot"),
17203            "memoized Rust declarations must preserve the public edge set"
17204        );
17205        assert_eq!(
17206            graph_table_rows(&uncached, "edges"),
17207            graph_table_rows(&cached, "edges"),
17208            "source, symbol, target, and provenance rows must be byte-identical"
17209        );
17210
17211        let expected_declaring_files = [
17212            "src/lib.rs",
17213            "src/module_0.rs",
17214            "src/module_1.rs",
17215            "src/module_2.rs",
17216            "src/module_3.rs",
17217            "src/module_4.rs",
17218        ]
17219        .into_iter()
17220        .map(str::to_string)
17221        .collect::<BTreeSet<_>>();
17222        let cached_parses = cached_memo.rust_declaration_parses_for_test();
17223        assert_eq!(
17224            cached_parses.keys().cloned().collect::<BTreeSet<_>>(),
17225            expected_declaring_files,
17226            "the fixture must traverse exactly its six distinct declaring files"
17227        );
17228        assert!(
17229            cached_parses.values().all(|count| *count == 1),
17230            "each declaring file must be parsed once per cold build: {cached_parses:?}"
17231        );
17232
17233        let uncached_parses = uncached_memo.rust_declaration_parses_for_test();
17234        let uncached_parse_total: usize = uncached_parses.values().sum();
17235        let cached_parse_total: usize = cached_parses.values().sum();
17236        println!(
17237            "RUST_DECLARATION_PARSE_COUNTS memo=off:{uncached_parse_total} memo=on:{cached_parse_total} distinct={}",
17238            cached_parses.len()
17239        );
17240        assert!(
17241            uncached_parse_total > 100,
17242            "mutation control: disabling memo insertion must repeat declaration parses; got {uncached_parse_total}"
17243        );
17244        let cached_missing_refs = cached
17245            .conn
17246            .lock()
17247            .expect("callgraph store mutex poisoned")
17248            .query_row(
17249                "SELECT COUNT(*) FROM refs
17250                 WHERE full_ref = 'crate::undeclared::missing' AND target_file IS NULL",
17251                [],
17252                |row| row.get::<_, usize>(0),
17253            )
17254            .expect("count unresolved negative refs");
17255        assert_eq!(
17256            cached_missing_refs, 10,
17257            "all negative-result references must remain unresolved without reparsing"
17258        );
17259    }
17260
17261    #[test]
17262    fn refresh_after_adding_rust_module_declaration_uses_fresh_snapshot() {
17263        let dir = tempdir().expect("temp dir");
17264        let project_root = dir.path().join("project");
17265        fs::create_dir_all(project_root.join("src/custom")).expect("create Rust fixture");
17266        fs::write(
17267            project_root.join("Cargo.toml"),
17268            "[package]\nname = \"refresh-declaration-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17269        )
17270        .expect("write Rust manifest");
17271        let lib = project_root.join("src/lib.rs");
17272        let existing = project_root.join("src/existing.rs");
17273        let added = project_root.join("src/custom/added.rs");
17274        fs::write(
17275            &lib,
17276            "mod existing;\npub fn run() { crate::added::target(); }\n",
17277        )
17278        .expect("write initial lib");
17279        fs::write(&existing, "pub fn existing() {}\n").expect("write existing module");
17280        fs::write(&added, "pub fn target() {}\n").expect("write added module target");
17281        let project_root = fs::canonicalize(project_root).expect("canonical project root");
17282        let lib = project_root.join("src/lib.rs");
17283        let existing = project_root.join("src/existing.rs");
17284        let added = project_root.join("src/custom/added.rs");
17285
17286        let store = CallGraphStore::open(
17287            dir.path().join("refresh-declaration-store"),
17288            project_root.to_path_buf(),
17289        )
17290        .expect("open refresh store");
17291        store
17292            .cold_build(&[lib.clone(), existing.clone(), added])
17293            .expect("initial cold build");
17294        assert!(
17295            store
17296                .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17297                .expect("initial callers")
17298                .is_empty(),
17299            "the custom-path module must be unresolved before its declaration exists"
17300        );
17301
17302        fs::write(&existing, "pub fn existing() { let _ = 1; }\n").expect("touch existing module");
17303        store
17304            .refresh_files(std::slice::from_ref(&existing))
17305            .expect("warm refresh declaration loading");
17306        fs::write(
17307            &lib,
17308            "mod existing;\n#[path = \"custom/added.rs\"]\nmod added;\npub fn run() { crate::added::target(); }\n",
17309        )
17310        .expect("add custom-path module declaration");
17311        store
17312            .refresh_files(std::slice::from_ref(&lib))
17313            .expect("refresh declaring file");
17314
17315        let callers = store
17316            .direct_callers_of(Path::new("src/custom/added.rs"), "target")
17317            .expect("refreshed callers");
17318        assert!(
17319            callers
17320                .iter()
17321                .any(|site| { site.caller.file == "src/lib.rs" && site.caller.symbol == "run" }),
17322            "a fresh refresh generation must resolve the newly declared module: {callers:#?}"
17323        );
17324    }
17325
17326    // Benchmark the cold resolver with and without memoization. The generated
17327    // workspace has hundreds of TypeScript files below a deep package-manifest
17328    // ladder and enough imported calls for filesystem resolution to dominate
17329    // the uncached run.
17330    #[test]
17331    #[ignore]
17332    fn bench_cold_build_resolution_memo() {
17333        let dir = tempdir().expect("temp dir");
17334        let project_root = dir.path().join("project");
17335        fs::create_dir_all(&project_root).expect("create benchmark root");
17336        let project_root = fs::canonicalize(project_root).expect("canonical benchmark root");
17337        let files = write_ts_resolution_memo_fixture(&project_root, 24, 12, 20);
17338        assert!(
17339            files.len() > 250,
17340            "benchmark fixture must contain hundreds of files"
17341        );
17342
17343        for enabled in [false, true] {
17344            callgraph::clear_workspace_package_cache();
17345            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, false);
17346            let store = CallGraphStore::open(
17347                dir.path().join(if enabled {
17348                    "store-cached"
17349                } else {
17350                    "store-uncached"
17351                }),
17352                project_root.to_path_buf(),
17353            )
17354            .expect("open benchmark store");
17355            let cpu_started = process_cpu_time();
17356            let wall_started = Instant::now();
17357            let stats = store
17358                .cold_build_chunked_with_resolution_memo_for_test(&files, 32, 257, &memo)
17359                .expect("benchmark cold build");
17360            let wall_ms = wall_started.elapsed().as_millis();
17361            let cpu_ms = process_cpu_time()
17362                .checked_sub(cpu_started)
17363                .unwrap_or_default()
17364                .as_millis();
17365            println!(
17366                "BENCH_COLD_BUILD_RESOLUTION_MEMO memo={} files={} refs={} edges={} wall_ms={} cpu_ms={}",
17367                if enabled { "on" } else { "off" },
17368                stats.files,
17369                stats.refs,
17370                stats.edges,
17371                wall_ms,
17372                cpu_ms
17373            );
17374        }
17375    }
17376
17377    #[test]
17378    #[ignore]
17379    fn bench_rust_declared_module_memo_real_corpus() {
17380        let project_root = fs::canonicalize(env!("CARGO_MANIFEST_DIR"))
17381            .expect("canonical agent-file-tools crate root");
17382        let files = [
17383            "src/main.rs",
17384            "src/cli/mod.rs",
17385            "src/cli/index.rs",
17386            "src/cli/sandbox_launch.rs",
17387            "src/cli/warmup.rs",
17388        ]
17389        .into_iter()
17390        .map(|path| project_root.join(path))
17391        .collect::<Vec<_>>();
17392        assert!(
17393            files.iter().all(|path| path.is_file()),
17394            "real-corpus benchmark sources must exist"
17395        );
17396        let dir = tempdir().expect("benchmark temp dir");
17397        let mut baseline_edges = None;
17398        let mut baseline_stats = None;
17399        let enabled_modes = match std::env::var("AFT_RUST_DECL_MEMO").as_deref() {
17400            Ok("off") => vec![false],
17401            Ok("on") => vec![true],
17402            _ => vec![false, true],
17403        };
17404
17405        for enabled in enabled_modes {
17406            let memo = callgraph::ModuleResolutionMemo::new_for_test(enabled, true);
17407            let store = CallGraphStore::open(
17408                dir.path().join(if enabled {
17409                    "rust-real-cached"
17410                } else {
17411                    "rust-real-uncached"
17412                }),
17413                project_root.to_path_buf(),
17414            )
17415            .expect("open real-corpus store");
17416            let phase_times = Arc::new(Mutex::new((None, None)));
17417            let observer_times = Arc::clone(&phase_times);
17418            set_cold_build_phase_observer(Some(Arc::new(move |phase| {
17419                let mut times = observer_times.lock().expect("phase timing mutex poisoned");
17420                match phase {
17421                    "resolution" if times.0.is_none() => times.0 = Some(Instant::now()),
17422                    "publication" if times.1.is_none() => times.1 = Some(Instant::now()),
17423                    _ => {}
17424                }
17425            })));
17426            let build = store.cold_build_chunked_with_resolution_memo_for_test(
17427                &files,
17428                COLD_BUILD_EXTRACT_BATCH_FILES,
17429                COLD_BUILD_RESOLVE_WINDOW,
17430                &memo,
17431            );
17432            set_cold_build_phase_observer(None);
17433            let stats = build.expect("real-corpus cold build");
17434            let times = phase_times.lock().expect("phase timing mutex poisoned");
17435            let resolution_elapsed = times
17436                .1
17437                .expect("publication phase timestamp")
17438                .duration_since(times.0.expect("resolution phase timestamp"));
17439            drop(times);
17440            let edges = graph_table_rows(&store, "edges");
17441            if let Some(expected) = &baseline_edges {
17442                assert_eq!(
17443                    &edges, expected,
17444                    "real-corpus edge rows, including provenance, must be byte-identical"
17445                );
17446            } else {
17447                baseline_edges = Some(edges);
17448            }
17449            let stats_tuple = (stats.files, stats.nodes, stats.refs, stats.edges);
17450            if let Some(expected) = baseline_stats {
17451                assert_eq!(stats_tuple, expected, "real-corpus build counts must match");
17452            } else {
17453                baseline_stats = Some(stats_tuple);
17454            }
17455            let parses = memo.rust_declaration_parses_for_test();
17456            println!(
17457                "BENCH_RUST_DECLARED_MODULE_MEMO memo={} files={} refs={} edges={} resolution_wall_ms={} declaration_parses={} distinct_declaring_files={}",
17458                if enabled { "on" } else { "off" },
17459                stats.files,
17460                stats.refs,
17461                stats.edges,
17462                resolution_elapsed.as_millis(),
17463                parses.values().sum::<usize>(),
17464                parses.len()
17465            );
17466        }
17467    }
17468
17469    // Perf A/B bench (not a gate): measures cold_build wall time at a given
17470    // chunk size against a real repo. Driven by env so the same binary can A/B
17471    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
17472    // memory work. Run:
17473    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
17474    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
17475    #[test]
17476    #[ignore]
17477    fn bench_cold_build_chunk() {
17478        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
17479        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
17480            .expect("AFT_PERF_CHUNK")
17481            .parse()
17482            .expect("AFT_PERF_CHUNK must be a non-negative integer");
17483        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
17484        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
17485        let dir = tempdir().expect("temp dir");
17486        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
17487            .expect("open store");
17488        let started = Instant::now();
17489        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
17490        let ms = started.elapsed().as_millis();
17491        println!(
17492            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
17493            stats.files, stats.nodes, stats.refs, stats.edges
17494        );
17495    }
17496
17497    #[test]
17498    fn persisted_workspace_reexport_selects_its_package_dependency() {
17499        let root = tempdir().expect("temp dir");
17500        let dependencies = BTreeSet::from([
17501            "packages/aft-bridge/src/index.ts".to_string(),
17502            "packages/opencode-plugin/src/types.ts".to_string(),
17503        ]);
17504        let indexed_files = dependencies.iter().cloned().collect::<HashSet<_>>();
17505
17506        assert_eq!(
17507            stored_dependencies_for_module(
17508                root.path(),
17509                "packages/opencode-plugin/src/shared/bash-hints.ts",
17510                "@cortexkit/aft-bridge",
17511                &dependencies,
17512                &indexed_files,
17513                &FactPaths {
17514                    root: root.path(),
17515                    facts: &DiskFacts::new(root.path())
17516                }
17517            ),
17518            BTreeSet::from(["packages/aft-bridge/src/index.ts".to_string()])
17519        );
17520    }
17521
17522    #[test]
17523    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
17524        let dir = tempdir().expect("temp dir");
17525        let project_root = dir.path();
17526        let files =
17527            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
17528        let index_path = project_root.join("src/index.ts");
17529
17530        let store = CallGraphStore::open(
17531            project_root.join(".store-incremental-barrel"),
17532            project_root.to_path_buf(),
17533        )
17534        .expect("open incremental store");
17535        store.cold_build(&files).expect("initial cold build");
17536
17537        {
17538            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
17539            let tx = conn.transaction().expect("dependency transaction");
17540            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
17541                .expect("dependent refs for barrel");
17542            let selected_ref_ids = dependent_refs
17543                .iter()
17544                .map(|dependent_ref| dependent_ref.ref_id.clone())
17545                .collect::<BTreeSet<_>>();
17546            let mut threaded_ref_ids = BTreeSet::new();
17547            let mut threaded_by_caller = BTreeMap::new();
17548            record_dependent_refs(
17549                &mut threaded_ref_ids,
17550                &mut threaded_by_caller,
17551                dependent_refs,
17552            );
17553            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
17554                .expect("old per-ref caller lookup");
17555
17556            assert_eq!(threaded_ref_ids, selected_ref_ids);
17557            assert_eq!(threaded_by_caller, old_by_caller);
17558            for consumer in [
17559                "src/consumer_a.ts",
17560                "src/consumer_b.ts",
17561                "src/consumer_c.ts",
17562            ] {
17563                assert!(
17564                    threaded_by_caller.contains_key(consumer),
17565                    "barrel edit should select dependent refs from {consumer}"
17566                );
17567            }
17568        }
17569
17570        fs::write(
17571            &index_path,
17572            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
17573        )
17574        .expect("edit barrel");
17575        let stats = store
17576            .refresh_files(std::slice::from_ref(&index_path))
17577            .expect("incremental refresh");
17578        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
17579        assert!(
17580            stats.dependency_selected_refs > 0,
17581            "barrel surface edit should select dependent refs"
17582        );
17583
17584        let cold_store = CallGraphStore::open(
17585            project_root.join(".store-cold-barrel"),
17586            project_root.to_path_buf(),
17587        )
17588        .expect("open cold rebuild store");
17589        cold_store
17590            .cold_build(&files)
17591            .expect("comparison cold build");
17592
17593        for table in [
17594            "nodes",
17595            "refs",
17596            "file_dependencies",
17597            "edges",
17598            "dispatch_hints",
17599        ] {
17600            assert_eq!(
17601                graph_table_rows(&store, table),
17602                graph_table_rows(&cold_store, table),
17603                "incremental refresh {table} rows must match cold rebuild"
17604            );
17605        }
17606
17607        let consumer_path = project_root.join("src/consumer_a.ts");
17608        fs::write(
17609            &consumer_path,
17610            "import { target } from \"./index\";\nexport function consumerA() { return target(); }\nexport const refreshed = true;\n",
17611        )
17612        .expect("edit barrel consumer");
17613        store
17614            .refresh_files(std::slice::from_ref(&consumer_path))
17615            .expect("refresh consumer through unchanged barrel");
17616        cold_store
17617            .cold_build(&files)
17618            .expect("comparison cold rebuild after consumer refresh");
17619        for table in [
17620            "nodes",
17621            "refs",
17622            "file_dependencies",
17623            "edges",
17624            "dispatch_hints",
17625        ] {
17626            assert_eq!(
17627                graph_table_rows(&store, table),
17628                graph_table_rows(&cold_store, table),
17629                "refresh through a persisted barrel must preserve cold-build {table} rows"
17630            );
17631        }
17632    }
17633
17634    fn build_reference_connection(
17635        project_root: &Path,
17636        extract: &FileExtract,
17637        resolved: &ResolvedRef,
17638    ) -> Connection {
17639        let mut conn = Connection::open_in_memory().expect("open reference db");
17640        configure_build_connection(&conn).expect("configure reference db");
17641        initialize_schema(&conn).expect("initialize reference schema");
17642        {
17643            let tx = conn.transaction().expect("reference transaction");
17644            clear_tables(&tx).expect("reference clear");
17645            insert_meta(&tx).expect("reference meta");
17646            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
17647            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
17648            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17649                .expect("reference dispatch edges");
17650            assert_eq!(supplemental, 0);
17651            tx.commit().expect("reference commit");
17652        }
17653        conn
17654    }
17655
17656    fn build_optimized_connection(
17657        project_root: &Path,
17658        extract: &FileExtract,
17659        resolved: &ResolvedRef,
17660    ) -> Connection {
17661        let mut conn = Connection::open_in_memory().expect("open optimized db");
17662        configure_build_connection(&conn).expect("configure optimized db");
17663        initialize_schema(&conn).expect("initialize optimized schema");
17664        {
17665            let tx = conn.transaction().expect("optimized transaction");
17666            clear_tables(&tx).expect("optimized clear");
17667            insert_meta(&tx).expect("optimized meta");
17668            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
17669            {
17670                let workspace_root = project_root.display().to_string();
17671                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
17672                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
17673                    .expect("optimized file extract");
17674                insert_resolved_ref_prepared(&mut inserts, resolved)
17675                    .expect("optimized resolved ref");
17676            }
17677            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
17678            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
17679                .expect("optimized dispatch edges");
17680            assert_eq!(supplemental, 0);
17681            tx.commit().expect("optimized commit");
17682        }
17683        conn
17684    }
17685
17686    fn fixture_extract(_project_root: &Path) -> FileExtract {
17687        let rel_path = "src/main.ts".to_string();
17688        let target_path = "src/helper.ts".to_string();
17689        let node = NodeRecord {
17690            id: "node-main".to_string(),
17691            file_path: rel_path.clone(),
17692            name: "main".to_string(),
17693            scoped_name: "main".to_string(),
17694            kind: "function".to_string(),
17695            range: Range {
17696                start_line: 0,
17697                start_col: 0,
17698                end_line: 0,
17699                end_col: 32,
17700            },
17701            range_ordinal: 0,
17702            signature: Some("export function main()".to_string()),
17703            exported: true,
17704            is_default_export: false,
17705            is_type_like: false,
17706            is_callgraph_entry_point: true,
17707        };
17708        let mut dependencies = BTreeSet::new();
17709        dependencies.insert(target_path.clone());
17710        let raw_ref = RawRef {
17711            ref_id: "ref-main-helper".to_string(),
17712            caller_node: Some(node.id.clone()),
17713            caller_symbol: Some(node.scoped_name.clone()),
17714            caller_file: rel_path.clone(),
17715            kind: "call".to_string(),
17716            short_name: Some("helper".to_string()),
17717            full_ref: Some("helper".to_string()),
17718            module_path: None,
17719            import_kind: None,
17720            local_name: Some("helper".to_string()),
17721            requested_name: Some("helper".to_string()),
17722            namespace_alias: None,
17723            wildcard: false,
17724            line: 1,
17725            byte_start: 24,
17726            byte_end: 32,
17727            dependencies,
17728        };
17729        FileExtract {
17730            rel_path,
17731            freshness: FileFreshness {
17732                mtime: UNIX_EPOCH + Duration::from_secs(123),
17733                size: 40,
17734                content_hash: cache_freshness::hash_bytes(b"fixture source"),
17735            },
17736            lang: LangId::TypeScript,
17737            data: FileCallData {
17738                calls_by_symbol: HashMap::new(),
17739                value_refs_by_symbol: HashMap::new(),
17740                exported_symbols: Vec::new(),
17741                symbol_metadata: HashMap::new(),
17742                default_export_symbol: None,
17743                import_block: ImportBlock::empty(),
17744                lang: LangId::TypeScript,
17745            },
17746            nodes: vec![node.clone()],
17747            raw_refs: vec![raw_ref],
17748            dispatch_hints: vec![DispatchHint {
17749                id: "dispatch-main-helper".to_string(),
17750                method_name: "helper".to_string(),
17751                caller_node: node.id,
17752                file: "src/main.ts".to_string(),
17753                line: 1,
17754                byte_start: 24,
17755                byte_end: 32,
17756            }],
17757            surface_fingerprint: "surface".to_string(),
17758        }
17759    }
17760
17761    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
17762        let raw = extract.raw_refs[0].clone();
17763        let mut dependencies = raw.dependencies.clone();
17764        dependencies.insert("src/helper.ts".to_string());
17765        ResolvedRef {
17766            edge: Some(EdgeRecord {
17767                edge_id: "edge-main-helper".to_string(),
17768                source_node: raw.caller_node.clone().expect("caller node"),
17769                target_node: Some("node-helper".to_string()),
17770                target_file: "src/helper.ts".to_string(),
17771                target_symbol: "helper".to_string(),
17772                kind: "call".to_string(),
17773                line: raw.line,
17774            }),
17775            raw,
17776            status: "resolved".to_string(),
17777            target_node: Some("node-helper".to_string()),
17778            target_file: Some("src/helper.ts".to_string()),
17779            target_symbol: Some("helper".to_string()),
17780            dependencies,
17781        }
17782    }
17783
17784    fn write_rust_declared_module_memo_fixture(
17785        project_root: &Path,
17786        calls_per_module: usize,
17787    ) -> Vec<PathBuf> {
17788        fs::create_dir_all(project_root.join("src")).expect("create Rust fixture root");
17789        fs::write(
17790            project_root.join("Cargo.toml"),
17791            "[package]\nname = \"rust-declaration-memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
17792        )
17793        .expect("write Rust fixture manifest");
17794
17795        let modules = (0..5)
17796            .map(|index| format!("module_{index}"))
17797            .collect::<Vec<_>>();
17798        let mut lib_source = modules
17799            .iter()
17800            .map(|module| format!("pub mod {module};\n"))
17801            .collect::<String>();
17802        lib_source.push_str("\npub fn dispatch() {\n");
17803        for call in 0..calls_per_module {
17804            for (index, module) in modules.iter().enumerate() {
17805                lib_source.push_str(&format!(
17806                    "    crate::{module}::leaf::target_{index}(); // call {call}\n"
17807                ));
17808            }
17809            lib_source.push_str("    crate::undeclared::missing();\n");
17810        }
17811        lib_source.push_str("}\n");
17812        let lib = project_root.join("src/lib.rs");
17813        fs::write(&lib, lib_source).expect("write Rust fixture lib");
17814        let mut files = vec![lib];
17815
17816        for (index, module) in modules.iter().enumerate() {
17817            let declaring_file = project_root.join(format!("src/{module}.rs"));
17818            fs::write(&declaring_file, "pub mod leaf;\n").expect("write nested module declaration");
17819            let target_file = project_root.join(format!("src/{module}/leaf.rs"));
17820            fs::create_dir_all(target_file.parent().expect("nested module parent"))
17821                .expect("create nested module directory");
17822            fs::write(&target_file, format!("pub fn target_{index}() {{}}\n"))
17823                .expect("write nested module target");
17824            files.push(declaring_file);
17825            files.push(target_file);
17826        }
17827        files
17828    }
17829
17830    fn write_ts_resolution_memo_fixture(
17831        project_root: &Path,
17832        package_count: usize,
17833        files_per_package: usize,
17834        calls_per_file: usize,
17835    ) -> Vec<PathBuf> {
17836        fs::create_dir_all(project_root).expect("create memo fixture root");
17837        fs::write(
17838            project_root.join("package.json"),
17839            r#"{"name":"fixture-root","private":true,"workspaces":["packages/*"]}"#,
17840        )
17841        .expect("write workspace package manifest");
17842        fs::write(
17843            project_root.join("tsconfig.json"),
17844            r#"{"compilerOptions":{"baseUrl":".","paths":{}}}"#,
17845        )
17846        .expect("write fixture tsconfig");
17847
17848        let shared_root = project_root.join("packages/shared");
17849        let shared_source = shared_root.join("src/index.ts");
17850        fs::create_dir_all(shared_source.parent().expect("shared source parent"))
17851            .expect("create shared package");
17852        fs::write(
17853            shared_root.join("package.json"),
17854            r#"{"name":"@fixture/shared","exports":{".":{"source":"./src/index.ts"}}}"#,
17855        )
17856        .expect("write shared package manifest");
17857        fs::write(
17858            &shared_source,
17859            "export function shared(value: number) { return value + 1; }\n",
17860        )
17861        .expect("write shared source");
17862        let mut files = vec![shared_source];
17863
17864        for package in 0..package_count {
17865            let package_root = project_root.join(format!("packages/app-{package:02}"));
17866            fs::create_dir_all(&package_root).expect("create app package");
17867            fs::write(
17868                package_root.join("package.json"),
17869                format!(r#"{{"name":"@fixture/app-{package:02}"}}"#),
17870            )
17871            .expect("write app package manifest");
17872            let source_dir = package_root.join("src/features/deep/nested/leaf");
17873            fs::create_dir_all(&source_dir).expect("create deep app source dir");
17874
17875            for file in 0..files_per_package {
17876                let source_path = source_dir.join(format!("caller_{file:03}.ts"));
17877                let mut source = "import { shared } from \"@fixture/shared\";\n".to_string();
17878                for call in 0..calls_per_file {
17879                    source.push_str(&format!(
17880                        "export function caller_{package}_{file}_{call}() {{ return shared({call}); }}\n"
17881                    ));
17882                }
17883                fs::write(&source_path, source).expect("write app source");
17884                files.push(source_path);
17885            }
17886        }
17887
17888        files
17889    }
17890
17891    #[cfg(unix)]
17892    fn process_cpu_time() -> Duration {
17893        let mut value = std::mem::MaybeUninit::<libc::timespec>::uninit();
17894        let result =
17895            unsafe { libc::clock_gettime(libc::CLOCK_PROCESS_CPUTIME_ID, value.as_mut_ptr()) };
17896        if result != 0 {
17897            return Duration::ZERO;
17898        }
17899        let value = unsafe { value.assume_init() };
17900        Duration::new(value.tv_sec.max(0) as u64, value.tv_nsec.max(0) as u32)
17901    }
17902
17903    #[cfg(not(unix))]
17904    fn process_cpu_time() -> Duration {
17905        Duration::ZERO
17906    }
17907
17908    fn write_chunked_equivalence_fixture(project_root: &Path) {
17909        let ts_dir = project_root.join("ts");
17910        fs::create_dir_all(&ts_dir).expect("create ts dir");
17911        fs::write(
17912            ts_dir.join("leaf.ts"),
17913            "export function leaf(value: number) {\n  return value + 1;\n}\n",
17914        )
17915        .expect("write ts leaf");
17916        fs::write(
17917            ts_dir.join("mid.ts"),
17918            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
17919        )
17920        .expect("write ts mid");
17921        fs::write(
17922            ts_dir.join("entry.ts"),
17923            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
17924        )
17925        .expect("write ts entry");
17926        fs::write(
17927            ts_dir.join("worker.ts"),
17928            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
17929        )
17930        .expect("write ts worker");
17931        for idx in 0..4 {
17932            fs::write(
17933                ts_dir.join(format!("extra_{idx}.ts")),
17934                format!(
17935                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
17936                ),
17937            )
17938            .expect("write ts extra");
17939        }
17940
17941        let rust_dir = project_root.join("src");
17942        let commands_dir = rust_dir.join("commands");
17943        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
17944        fs::write(
17945            rust_dir.join("context.rs"),
17946            r#"pub struct AppContext;
17947
17948impl AppContext {
17949    pub fn callgraph_store_for_ops(&self) -> usize {
17950        1
17951    }
17952}
17953"#,
17954        )
17955        .expect("write rust context");
17956        fs::write(
17957            rust_dir.join("lib.rs"),
17958            "pub mod context;\npub mod commands;\n",
17959        )
17960        .expect("write rust lib");
17961        fs::write(
17962            commands_dir.join("mod.rs"),
17963            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
17964        )
17965        .expect("write rust commands mod");
17966        for name in ["callers", "impact", "trace_to"] {
17967            fs::write(
17968                commands_dir.join(format!("{name}.rs")),
17969                format!(
17970                    r#"use crate::context::AppContext;
17971
17972pub fn handle_{name}(ctx: &AppContext) -> usize {{
17973    ctx.callgraph_store_for_ops()
17974}}
17975"#
17976                ),
17977            )
17978            .expect("write rust command");
17979        }
17980    }
17981
17982    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
17983        let src_dir = project_root.join("src");
17984        fs::create_dir_all(&src_dir).expect("create src dir");
17985
17986        let target_path = src_dir.join("target.ts");
17987        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
17988            .expect("write target");
17989
17990        let index_path = src_dir.join("index.ts");
17991        fs::write(&index_path, barrel_source).expect("write barrel");
17992
17993        let mut files = vec![target_path, index_path];
17994        for (file_name, function_name) in [
17995            ("consumer_a.ts", "consumerA"),
17996            ("consumer_b.ts", "consumerB"),
17997            ("consumer_c.ts", "consumerC"),
17998        ] {
17999            let path = src_dir.join(file_name);
18000            fs::write(
18001                &path,
18002                format!(
18003                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
18004                ),
18005            )
18006            .expect("write consumer");
18007            files.push(path);
18008        }
18009        files
18010    }
18011
18012    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
18013        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18014        table_rows(&conn, table)
18015    }
18016
18017    fn graph_table_rows_without(
18018        store: &CallGraphStore,
18019        table: &str,
18020        excluded_columns: &[&str],
18021    ) -> Vec<String> {
18022        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
18023        table_rows_without(&conn, table, excluded_columns)
18024    }
18025
18026    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
18027        table_rows_without(conn, table, &[])
18028    }
18029
18030    fn table_rows_without(
18031        conn: &Connection,
18032        table: &str,
18033        excluded_columns: &[&str],
18034    ) -> Vec<String> {
18035        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
18036        let columns: Vec<String> = conn
18037            .prepare(&format!("PRAGMA table_info({table})"))
18038            .expect("prepare table_info")
18039            .query_map([], |row| row.get::<_, String>(1))
18040            .expect("query table_info")
18041            .collect::<std::result::Result<Vec<String>, _>>()
18042            .expect("collect columns")
18043            .into_iter()
18044            .filter(|column| !excluded_columns.contains(column.as_str()))
18045            .collect();
18046        let sql = format!(
18047            "SELECT {} FROM {table} ORDER BY {}",
18048            columns.join(", "),
18049            columns.join(", ")
18050        );
18051        conn.prepare(&sql)
18052            .expect("prepare table rows")
18053            .query_map([], |row| row_to_strings(row, columns.len()))
18054            .expect("query table rows")
18055            .collect::<std::result::Result<_, _>>()
18056            .expect("collect table rows")
18057    }
18058
18059    fn assert_cold_build_stats_match_except_elapsed(
18060        expected: &ColdBuildStats,
18061        actual: &ColdBuildStats,
18062    ) {
18063        assert_eq!(actual.files, expected.files, "file counts must match");
18064        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
18065        assert_eq!(actual.refs, expected.refs, "ref counts must match");
18066        assert_eq!(actual.edges, expected.edges, "edge counts must match");
18067        assert_eq!(
18068            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
18069            expected
18070                .failed_files
18071                .iter()
18072                .cloned()
18073                .collect::<BTreeSet<_>>(),
18074            "failed file sets must match"
18075        );
18076    }
18077
18078    fn backend_state_rows(conn: &Connection) -> Vec<String> {
18079        conn.prepare(
18080            "SELECT backend, workspace_root, file_path, content_hash, status
18081             FROM backend_file_state
18082             ORDER BY backend, workspace_root, file_path, content_hash, status",
18083        )
18084        .expect("prepare backend rows")
18085        .query_map([], |row| row_to_strings(row, 5))
18086        .expect("query backend rows")
18087        .collect::<std::result::Result<_, _>>()
18088        .expect("collect backend rows")
18089    }
18090
18091    fn secondary_indexes(conn: &Connection) -> Vec<String> {
18092        let mut indexes = Vec::new();
18093        for table in [
18094            "files",
18095            "nodes",
18096            "refs",
18097            "file_dependencies",
18098            "edges",
18099            "dispatch_hints",
18100            "type_ref_names",
18101            "backend_file_state",
18102            "meta",
18103        ] {
18104            let sql = format!("PRAGMA index_list({table})");
18105            let mut stmt = conn.prepare(&sql).expect("prepare index list");
18106            let rows = stmt
18107                .query_map([], |row| row.get::<_, String>(1))
18108                .expect("query index list");
18109            for name in rows {
18110                let name = name.expect("index name");
18111                if name.starts_with("idx_") {
18112                    indexes.push(format!("{table}:{name}"));
18113                }
18114            }
18115        }
18116        indexes.sort();
18117        indexes
18118    }
18119
18120    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
18121        let mut values = Vec::with_capacity(len);
18122        for index in 0..len {
18123            let value = row.get_ref(index)?;
18124            values.push(match value {
18125                rusqlite::types::ValueRef::Null => "NULL".to_string(),
18126                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
18127                rusqlite::types::ValueRef::Real(value) => value.to_string(),
18128                rusqlite::types::ValueRef::Text(value) => {
18129                    String::from_utf8_lossy(value).into_owned()
18130                }
18131                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
18132            });
18133        }
18134        Ok(values.join("\u{1f}"))
18135    }
18136}
18137
18138#[cfg(test)]
18139mod rust_resolution_tests {
18140    use super::*;
18141    use crate::inspect::job::CallgraphSnapshot;
18142    use std::fs;
18143    use tempfile::tempdir;
18144
18145    #[test]
18146    fn rust_function_scoped_module_alias_resolves_and_projects_live() {
18147        let dir = tempdir().expect("tempdir");
18148        let root = dir.path();
18149        write_rust_manifest(root, "scoped-alias-fixture");
18150        write_file(
18151            root,
18152            "src/lib.rs",
18153            r#"pub mod finalization_contract;
18154
18155pub fn run_alias() {
18156    use crate::finalization_contract as fc;
18157    fc::check_mason_contract();
18158}
18159"#,
18160        );
18161        write_file(
18162            root,
18163            "src/finalization_contract.rs",
18164            r#"pub fn check_mason_contract() {}
18165fn planted_dead() {}
18166"#,
18167        );
18168
18169        let (store, snapshot) = cold_build_twice(root);
18170        assert_direct_caller(
18171            &store,
18172            "src/finalization_contract.rs",
18173            "check_mason_contract",
18174            "src/lib.rs",
18175            "run_alias",
18176        );
18177        assert_projected_call(
18178            root,
18179            &snapshot,
18180            "src/finalization_contract.rs",
18181            "check_mason_contract",
18182        );
18183        assert_no_projected_call(
18184            root,
18185            &snapshot,
18186            "src/finalization_contract.rs",
18187            "planted_dead",
18188        );
18189        assert!(
18190            store
18191                .direct_callers_of(Path::new("src/finalization_contract.rs"), "planted_dead")
18192                .expect("planted dead callers")
18193                .is_empty(),
18194            "planted-dead guard should stay without callers"
18195        );
18196    }
18197
18198    #[test]
18199    fn rust_inline_sibling_module_qualified_calls_resolve_scoped_targets() {
18200        let dir = tempdir().expect("tempdir");
18201        let root = dir.path();
18202        write_rust_manifest(root, "inline-module-fixture");
18203        write_file(
18204            root,
18205            "src/lib.rs",
18206            r#"mod work_graph { fn operations() {} }
18207mod manifest { fn operations() {} }
18208mod audit { fn operations() {} }
18209mod dispatch { fn operations() {} }
18210mod finalization { fn operations() {} }
18211
18212pub fn run_inline_operations() {
18213    work_graph::operations();
18214    manifest::operations();
18215    audit::operations();
18216    dispatch::operations();
18217    finalization::operations();
18218}
18219
18220fn planted_dead() {}
18221"#,
18222        );
18223
18224        let (store, snapshot) = cold_build_twice(root);
18225        for module in [
18226            "work_graph",
18227            "manifest",
18228            "audit",
18229            "dispatch",
18230            "finalization",
18231        ] {
18232            assert_direct_caller(
18233                &store,
18234                "src/lib.rs",
18235                &format!("{module}::operations"),
18236                "src/lib.rs",
18237                "run_inline_operations",
18238            );
18239        }
18240        assert_projected_call(root, &snapshot, "src/lib.rs", "operations");
18241        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18242    }
18243
18244    #[test]
18245    fn rust_workspace_pub_use_reexport_resolves_to_source_file() {
18246        let dir = tempdir().expect("tempdir");
18247        let root = dir.path();
18248        fs::write(
18249            root.join("Cargo.toml"),
18250            "[workspace]\nresolver = \"2\"\nmembers = [\"crates/but-action\", \"crates/app\"]\n",
18251        )
18252        .expect("write workspace manifest");
18253        write_file(
18254            root,
18255            "crates/but-action/Cargo.toml",
18256            r#"[package]
18257name = "but-action"
18258version = "0.1.0"
18259edition = "2021"
18260"#,
18261        );
18262        write_file(
18263            root,
18264            "crates/but-action/src/lib.rs",
18265            "mod action;\npub use action::{list_actions};\n",
18266        );
18267        write_file(
18268            root,
18269            "crates/but-action/src/action.rs",
18270            "pub fn list_actions() {}\nfn planted_dead() {}\n",
18271        );
18272        write_file(
18273            root,
18274            "crates/app/Cargo.toml",
18275            r#"[package]
18276name = "app"
18277version = "0.1.0"
18278edition = "2021"
18279"#,
18280        );
18281        write_file(
18282            root,
18283            "crates/app/src/lib.rs",
18284            "pub fn run_actions() {\n    but_action::list_actions();\n}\n",
18285        );
18286
18287        let (store, snapshot) = cold_build_twice(root);
18288        assert_direct_caller(
18289            &store,
18290            "crates/but-action/src/action.rs",
18291            "list_actions",
18292            "crates/app/src/lib.rs",
18293            "run_actions",
18294        );
18295        assert!(
18296            store
18297                .direct_callers_of(Path::new("crates/but-action/src/lib.rs"), "list_actions")
18298                .expect("lib reexport callers")
18299                .is_empty(),
18300            "call should target the reexported source function, not lib.rs"
18301        );
18302        assert_projected_call(
18303            root,
18304            &snapshot,
18305            "crates/but-action/src/action.rs",
18306            "list_actions",
18307        );
18308        assert_no_projected_call(
18309            root,
18310            &snapshot,
18311            "crates/but-action/src/action.rs",
18312            "planted_dead",
18313        );
18314    }
18315
18316    #[test]
18317    fn rust_cfg_attributed_module_resolves_outgoing_calls() {
18318        let dir = tempdir().expect("tempdir");
18319        let root = dir.path();
18320        write_rust_manifest(root, "cfg-module-outgoing-fixture");
18321        write_file(
18322            root,
18323            "src/lib.rs",
18324            "pub fn project_range() {}\n\n#[cfg(any(test, feature = \"test-conformance\"))]\npub mod conformance;\npub mod ordinary;\n",
18325        );
18326        for module in ["conformance", "ordinary"] {
18327            write_file(
18328                root,
18329                &format!("src/{module}.rs"),
18330                "use crate::project_range;\n\npub fn local_target() {}\n\npub fn run() {\n    local_target();\n    project_range();\n}\n",
18331            );
18332        }
18333
18334        let (store, _) = cold_build_twice(root);
18335        for module in ["conformance", "ordinary"] {
18336            assert_direct_caller(
18337                &store,
18338                &format!("src/{module}.rs"),
18339                "local_target",
18340                &format!("src/{module}.rs"),
18341                "run",
18342            );
18343            assert_direct_caller(
18344                &store,
18345                "src/lib.rs",
18346                "project_range",
18347                &format!("src/{module}.rs"),
18348                "run",
18349            );
18350        }
18351    }
18352
18353    #[test]
18354    fn rust_registered_modules_preserve_import_alias_resolution() {
18355        let dir = tempdir().expect("tempdir");
18356        let root = dir.path();
18357        write_rust_manifest(root, "registered-module-import-control");
18358        write_file(
18359            root,
18360            "src/main.rs",
18361            "mod commands;\nmod db;\nfn main() {}\n",
18362        );
18363        write_file(
18364            root,
18365            "src/commands.rs",
18366            "use crate::db;\n\npub fn run() {\n    db::helper();\n}\n",
18367        );
18368        write_file(root, "src/db.rs", "pub fn helper() {}\n");
18369
18370        let main_extract =
18371            build_file_extract(root, &root.join("src/main.rs")).expect("main extract");
18372        let commands_extract =
18373            build_file_extract(root, &root.join("src/commands.rs")).expect("commands extract");
18374        let db_extract = build_file_extract(root, &root.join("src/db.rs")).expect("db extract");
18375        let files = [&main_extract, &commands_extract, &db_extract]
18376            .into_iter()
18377            .map(|extract| {
18378                (
18379                    extract.rel_path.clone(),
18380                    DbFileIndex::from_extract(
18381                        root,
18382                        extract,
18383                        &FactPaths {
18384                            root,
18385                            facts: &DiskFacts::new(root),
18386                        },
18387                    ),
18388                )
18389            })
18390            .collect::<HashMap<_, _>>();
18391        let caller_data = [&main_extract, &commands_extract, &db_extract]
18392            .into_iter()
18393            .map(|extract| (extract.rel_path.clone(), &extract.data))
18394            .collect::<HashMap<_, _>>();
18395        let index = ProjectIndex::from_parts(
18396            root,
18397            files,
18398            caller_data,
18399            WorkspaceCratePrefixCache::default(),
18400            Rc::new(DiskFacts::new(root)),
18401        );
18402        assert_eq!(
18403            index.module_parent("src/commands.rs"),
18404            Some(("src/main.rs".to_string(), "commands".to_string()))
18405        );
18406        assert_eq!(
18407            index.module_target("src/main.rs", "db").as_deref(),
18408            Some("src/db.rs")
18409        );
18410        let call = commands_extract
18411            .raw_refs
18412            .iter()
18413            .find(|raw| raw.kind == "call" && raw.full_ref.as_deref() == Some("db::helper"))
18414            .expect("db helper call")
18415            .clone();
18416        let resolved = resolve_ref(call, &index).expect("resolve db helper");
18417        assert_eq!(resolved.target_file.as_deref(), Some("src/db.rs"));
18418        assert_eq!(resolved.target_symbol.as_deref(), Some("helper"));
18419
18420        let (store, _) = cold_build_twice(root);
18421        assert_direct_caller(&store, "src/db.rs", "helper", "src/commands.rs", "run");
18422    }
18423
18424    #[test]
18425    fn rust_path_attributed_module_uses_declared_logical_parent() {
18426        let dir = tempdir().expect("tempdir");
18427        let root = dir.path();
18428        write_rust_manifest(root, "path-module-outgoing-fixture");
18429        write_file(
18430            root,
18431            "src/lib.rs",
18432            "pub fn project_range() {}\n\n#[cfg(test)]\n#[path = \"alternate/custom.rs\"]\npub mod conformance;\n",
18433        );
18434        write_file(
18435            root,
18436            "src/alternate/custom.rs",
18437            "pub fn run() {\n    super::project_range();\n}\n",
18438        );
18439
18440        let (store, _) = cold_build_twice(root);
18441        assert_direct_caller(
18442            &store,
18443            "src/lib.rs",
18444            "project_range",
18445            "src/alternate/custom.rs",
18446            "run",
18447        );
18448    }
18449
18450    #[test]
18451    fn rust_same_file_test_module_receiver_method_dispatch_resolves() {
18452        let dir = tempdir().expect("tempdir");
18453        let root = dir.path();
18454        write_rust_manifest(root, "same-file-test-module-fixture");
18455        write_file(
18456            root,
18457            "src/lib.rs",
18458            r#"pub struct Index(u32);
18459
18460impl Index {
18461    pub fn shares_index_with(&self, other: &Self) -> bool {
18462        self.0 == other.0
18463    }
18464}
18465
18466#[cfg(test)]
18467mod tests {
18468    use super::Index;
18469
18470    #[test]
18471    fn compares_indexes() {
18472        let before = Index(1);
18473        let after = Index(1);
18474        assert!(before.shares_index_with(&after));
18475    }
18476}
18477"#,
18478        );
18479
18480        let (store, snapshot) = cold_build_twice(root);
18481        assert_direct_caller(
18482            &store,
18483            "src/lib.rs",
18484            "Index::shares_index_with",
18485            "src/lib.rs",
18486            "tests::compares_indexes",
18487        );
18488        assert!(
18489            snapshot.outbound_calls.iter().any(|call| {
18490                call.caller_symbol == "compares_indexes"
18491                    && call.line == 17
18492                    && call.target.starts_with(&format!(
18493                        "shares_index_with{}before.shares_index_with",
18494                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18495                    ))
18496            }),
18497            "expected projected macro receiver call; calls: {:#?}",
18498            snapshot.outbound_calls
18499        );
18500    }
18501
18502    #[test]
18503    fn rust_generic_self_turbofish_method_dispatch_resolves() {
18504        let dir = tempdir().expect("tempdir");
18505        let root = dir.path();
18506        write_rust_manifest(root, "generic-self-fixture");
18507        write_file(
18508            root,
18509            "src/lib.rs",
18510            r#"pub struct Matcher;
18511
18512impl Matcher {
18513    pub fn run(&self) -> bool {
18514        self.fuzzy_match_optimal::<usize>("needle")
18515    }
18516
18517    fn fuzzy_match_optimal<T>(&self, _needle: &str) -> bool {
18518        let _ = std::marker::PhantomData::<T>;
18519        true
18520    }
18521
18522    fn planted_dead(&self) {}
18523}
18524
18525pub fn entry() -> bool {
18526    let matcher = Matcher;
18527    matcher.run()
18528}
18529"#,
18530        );
18531
18532        let (store, snapshot) = cold_build_twice(root);
18533        assert_direct_caller(
18534            &store,
18535            "src/lib.rs",
18536            "Matcher::fuzzy_match_optimal",
18537            "src/lib.rs",
18538            "Matcher::run",
18539        );
18540        assert_projected_call(root, &snapshot, "src/lib.rs", "fuzzy_match_optimal");
18541        assert_no_projected_call(root, &snapshot, "src/lib.rs", "planted_dead");
18542    }
18543
18544    #[test]
18545    fn rust_manifest_operations_named_import_is_not_the_missing_edge() {
18546        let dir = tempdir().expect("tempdir");
18547        let root = dir.path();
18548        write_rust_manifest(root, "manifest-operations-fixture");
18549        write_file(
18550            root,
18551            "src/main.rs",
18552            r#"mod dispatch;
18553use dispatch::{manifest_operations};
18554
18555fn main() {
18556    manifest_operations();
18557}
18558"#,
18559        );
18560        write_file(
18561            root,
18562            "src/dispatch.rs",
18563            r#"mod work_graph { fn operations() {} }
18564mod manifest { fn operations() {} }
18565mod audit { fn operations() {} }
18566mod descriptor { fn operations() {} }
18567mod writer { fn operations() {} }
18568
18569pub fn manifest_operations() {
18570    manifest::operations();
18571}
18572
18573pub fn work_graph_operations() {
18574    work_graph::operations();
18575}
18576
18577pub fn audit_operations() {
18578    audit::operations();
18579}
18580
18581pub fn descriptor_operations() {
18582    descriptor::operations();
18583}
18584
18585pub fn writer_operations() {
18586    writer::operations();
18587}
18588
18589fn planted_dead() {}
18590"#,
18591        );
18592
18593        let (store, snapshot) = cold_build_twice(root);
18594        assert_direct_caller(
18595            &store,
18596            "src/dispatch.rs",
18597            "manifest_operations",
18598            "src/main.rs",
18599            "main",
18600        );
18601        assert_direct_caller(
18602            &store,
18603            "src/dispatch.rs",
18604            "manifest::operations",
18605            "src/dispatch.rs",
18606            "manifest_operations",
18607        );
18608        assert_projected_call(root, &snapshot, "src/dispatch.rs", "manifest_operations");
18609        assert_projected_call(root, &snapshot, "src/dispatch.rs", "operations");
18610        assert_no_projected_call(root, &snapshot, "src/dispatch.rs", "planted_dead");
18611    }
18612
18613    fn cold_build_twice(root: &Path) -> (CallGraphStore, CallgraphSnapshot) {
18614        let files = rust_files(root);
18615        let first = CallGraphStore::open(root.join(".store-first"), root.to_path_buf())
18616            .expect("open first store");
18617        first.cold_build(&files).expect("first cold build");
18618        let first_snapshot =
18619            project_dead_code_snapshot(first.sqlite_path()).expect("first projected snapshot");
18620
18621        let second = CallGraphStore::open(root.join(".store-second"), root.to_path_buf())
18622            .expect("open second store");
18623        second.cold_build(&files).expect("second cold build");
18624        let second_snapshot =
18625            project_dead_code_snapshot(second.sqlite_path()).expect("second projected snapshot");
18626
18627        assert_eq!(
18628            projection_rows(&first_snapshot),
18629            projection_rows(&second_snapshot),
18630            "cold-build projection should be deterministic"
18631        );
18632        (first, first_snapshot)
18633    }
18634
18635    fn projection_rows(snapshot: &CallgraphSnapshot) -> Vec<String> {
18636        let mut rows = Vec::new();
18637        for export in &snapshot.exported_symbols {
18638            rows.push(format!(
18639                "export\t{}\t{}\t{}\t{}",
18640                export.file.display(),
18641                export.symbol,
18642                export.kind,
18643                export.line
18644            ));
18645        }
18646        for call in &snapshot.outbound_calls {
18647            rows.push(format!(
18648                "call\t{}\t{}\t{}\t{}\t{}",
18649                call.caller_file.display(),
18650                call.caller_symbol,
18651                call.target,
18652                call.line,
18653                call.provenance
18654            ));
18655        }
18656        for file in &snapshot.entry_points {
18657            rows.push(format!("entry_file\t{}", file.display()));
18658        }
18659        for (file, symbols) in &snapshot.entry_point_symbols {
18660            for symbol in symbols {
18661                rows.push(format!("entry_symbol\t{}\t{symbol}", file.display()));
18662            }
18663        }
18664        rows.sort();
18665        rows
18666    }
18667
18668    fn assert_direct_caller(
18669        store: &CallGraphStore,
18670        target_rel: &str,
18671        target_symbol: &str,
18672        caller_rel: &str,
18673        caller_symbol: &str,
18674    ) {
18675        let callers = store
18676            .direct_callers_of(Path::new(target_rel), target_symbol)
18677            .unwrap_or_else(|error| {
18678                panic!("direct callers for {target_rel}::{target_symbol}: {error}")
18679            });
18680        assert!(
18681            callers.iter().any(|site| {
18682                site.caller.file == caller_rel && site.caller.symbol == caller_symbol
18683            }),
18684            "expected {caller_rel}::{caller_symbol} to call {target_rel}::{target_symbol}; callers: {callers:#?}"
18685        );
18686    }
18687
18688    fn assert_projected_call(
18689        root: &Path,
18690        snapshot: &CallgraphSnapshot,
18691        target_rel: &str,
18692        symbol: &str,
18693    ) {
18694        let target = projected_target(root, target_rel, symbol);
18695        assert!(
18696            snapshot.outbound_calls.iter().any(|call| {
18697                call.target == target
18698                    || call.target.starts_with(&format!(
18699                        "{target}{}",
18700                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18701                    ))
18702            }),
18703            "expected projected call to {target}; calls: {:#?}",
18704            snapshot.outbound_calls
18705        );
18706    }
18707
18708    fn assert_no_projected_call(
18709        root: &Path,
18710        snapshot: &CallgraphSnapshot,
18711        target_rel: &str,
18712        symbol: &str,
18713    ) {
18714        let target = projected_target(root, target_rel, symbol);
18715        assert!(
18716            snapshot.outbound_calls.iter().all(|call| {
18717                call.target != target
18718                    && !call.target.starts_with(&format!(
18719                        "{target}{}",
18720                        crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR
18721                    ))
18722            }),
18723            "did not expect projected call to {target}; calls: {:#?}",
18724            snapshot.outbound_calls
18725        );
18726    }
18727
18728    fn projected_target(root: &Path, target_rel: &str, symbol: &str) -> String {
18729        // Projection targets carry the normalized (verbatim-stripped)
18730        // canonical form; bare fs::canonicalize diverges on Windows.
18731        let path = crate::inspect::job::canonicalize_normalized(&root.join(target_rel));
18732        format!("{}::{symbol}", path.display())
18733    }
18734
18735    fn write_rust_manifest(root: &Path, name: &str) {
18736        write_file(
18737            root,
18738            "Cargo.toml",
18739            &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
18740        );
18741    }
18742
18743    fn write_file(root: &Path, rel_path: &str, source: &str) -> PathBuf {
18744        let path = root.join(rel_path);
18745        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
18746        fs::write(&path, source).expect("write fixture file");
18747        path
18748    }
18749
18750    fn rust_files(root: &Path) -> Vec<PathBuf> {
18751        let mut files = Vec::new();
18752        collect_rust_files(root, &mut files);
18753        files.sort();
18754        files
18755    }
18756
18757    fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
18758        for entry in fs::read_dir(dir).expect("read fixture dir") {
18759            let entry = entry.expect("read fixture entry");
18760            let path = entry.path();
18761            if path.is_dir() {
18762                let name = path
18763                    .file_name()
18764                    .and_then(|name| name.to_str())
18765                    .unwrap_or("");
18766                if !name.starts_with(".store") {
18767                    collect_rust_files(&path, files);
18768                }
18769            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
18770                files.push(path);
18771            }
18772        }
18773    }
18774}
18775
18776#[cfg(test)]
18777mod build_pool_tests {
18778    use super::build_pool_size;
18779
18780    #[test]
18781    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
18782        let size = build_pool_size();
18783        // Never zero, never the full core count, never above the 8 cap — this is
18784        // the starvation guard for the cold-build's all-cores tree-sitter pass.
18785        assert!(size >= 1, "pool size must be at least 1");
18786        assert!(size <= 8, "pool size must be capped at 8, got {size}");
18787
18788        let cores = std::thread::available_parallelism()
18789            .map(|p| p.get())
18790            .unwrap_or(1);
18791        let expected = cores.div_ceil(2).clamp(1, 8);
18792        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
18793    }
18794}
18795
18796#[cfg(test)]
18797mod reexport_resolution_tests {
18798    use super::*;
18799
18800    fn barrel_index(files: Vec<(String, DbFileIndex)>) -> ProjectIndex<'static> {
18801        ProjectIndex {
18802            facts: Rc::new(DiskFacts::new(Path::new("/fixture"))),
18803            unbound_non_utf8_paths: Vec::new(),
18804            project_root: PathBuf::from("/fixture"),
18805            files: files.into_iter().collect(),
18806            caller_data: HashMap::new(),
18807            workspace_crate_prefixes: WorkspaceCratePrefixCache::default(),
18808            rust_crate_roots: callgraph::RustCrateRootMemo::default(),
18809        }
18810    }
18811
18812    fn barrel_file(reexport_targets: &[&str]) -> DbFileIndex {
18813        DbFileIndex {
18814            lang: None,
18815            exports: HashSet::new(),
18816            default_export: None,
18817            export_aliases: HashMap::new(),
18818            node_by_scoped: HashMap::new(),
18819            node_by_bare: HashMap::new(),
18820            node_kind_by_id: HashMap::new(),
18821            module_targets: HashMap::new(),
18822            declared_module_targets: HashMap::new(),
18823            reexports: reexport_targets
18824                .iter()
18825                .map(|target| ReexportIndex {
18826                    target_file: Some((*target).to_string()),
18827                    named: HashMap::new(),
18828                    wildcard: true,
18829                })
18830                .collect(),
18831        }
18832    }
18833
18834    /// A dense wildcard re-export cycle (barrel files re-exporting each
18835    /// other) must resolve in O(files), not O(branching^depth). Without the
18836    /// resolver's memoization, resolving a MISSING symbol through this
18837    /// 12-file complete digraph explores ~11^16 paths and this test never
18838    /// finishes: the depth cap bounds path length, not path count, and one
18839    /// such resolution can pin a worker thread at 100% CPU indefinitely.
18840    #[test]
18841    fn missing_symbol_in_dense_wildcard_reexport_cycle_terminates() {
18842        let names: Vec<String> = (0..12).map(|i| format!("src/barrel{i}.ts")).collect();
18843        let files = names
18844            .iter()
18845            .map(|name| {
18846                let targets: Vec<&str> = names
18847                    .iter()
18848                    .filter(|other| *other != name)
18849                    .map(String::as_str)
18850                    .collect();
18851                (name.clone(), barrel_file(&targets))
18852            })
18853            .collect();
18854        let index = barrel_index(files);
18855
18856        assert_eq!(
18857            resolve_exported_symbol(&index, "src/barrel0.ts", "does_not_exist", 0),
18858            None
18859        );
18860    }
18861
18862    /// Depth-dominance counterexample: the walk first reaches `shared` down a
18863    /// 16-hop chain (no budget left for its leaf), then reaches it again
18864    /// directly at depth 1. Plain visited-set pruning would skip the second
18865    /// visit and lose a resolution the capped resolver finds; the
18866    /// depth-dominance memo revisits because the second arrival is shallower.
18867    #[test]
18868    fn shallow_revisit_after_deep_capped_visit_still_resolves() {
18869        let mut leaf = barrel_file(&[]);
18870        leaf.exports.insert("deep_symbol".to_string());
18871        let mut files: Vec<(String, DbFileIndex)> = Vec::new();
18872        // entry -> chain0 -> chain1 -> ... -> chain14 -> shared -> leaf
18873        // entry's SECOND reexport goes straight to shared.
18874        files.push((
18875            "src/entry.ts".to_string(),
18876            barrel_file(&["src/chain0.ts", "src/shared.ts"]),
18877        ));
18878        for i in 0..15 {
18879            let next = if i == 14 {
18880                "src/shared.ts".to_string()
18881            } else {
18882                format!("src/chain{}.ts", i + 1)
18883            };
18884            files.push((format!("src/chain{i}.ts"), barrel_file(&[&next])));
18885        }
18886        files.push(("src/shared.ts".to_string(), barrel_file(&["src/leaf.ts"])));
18887        files.push(("src/leaf.ts".to_string(), leaf));
18888        let index = barrel_index(files);
18889
18890        assert_eq!(
18891            resolve_exported_symbol(&index, "src/entry.ts", "deep_symbol", 0),
18892            Some(("src/leaf.ts".to_string(), "deep_symbol".to_string())),
18893            "a shallower re-visit must not be pruned by a deeper capped visit"
18894        );
18895    }
18896
18897    #[test]
18898    fn symbol_reachable_through_reexport_cycle_still_resolves() {
18899        let mut leaf = barrel_file(&[]);
18900        leaf.exports.insert("real_symbol".to_string());
18901        let index = barrel_index(vec![
18902            (
18903                "src/a.ts".to_string(),
18904                barrel_file(&["src/b.ts", "src/a.ts"]),
18905            ),
18906            (
18907                "src/b.ts".to_string(),
18908                barrel_file(&["src/a.ts", "src/leaf.ts"]),
18909            ),
18910            ("src/leaf.ts".to_string(), leaf),
18911        ]);
18912
18913        assert_eq!(
18914            resolve_exported_symbol(&index, "src/a.ts", "real_symbol", 0),
18915            Some(("src/leaf.ts".to_string(), "real_symbol".to_string()))
18916        );
18917    }
18918}
18919
18920#[cfg(test)]
18921mod method_dispatch_inference_tests {
18922    use super::*;
18923    use std::fs;
18924    use tempfile::tempdir;
18925
18926    #[test]
18927    fn java_field_receiver_type_selects_declared_class_method() {
18928        let source = r#"class EntryPoint {
18929    private UserService userService;
18930
18931    void handle() {
18932        userService.find();
18933    }
18934}
18935
18936class UserService {
18937    void find() {}
18938}
18939
18940class AuditService {
18941    void find() {}
18942}
18943"#;
18944        let dir = tempdir().expect("temp dir");
18945        let root = dir.path();
18946        write_fixture(root, "src/EntryPoint.java", source);
18947        let reference = reference(
18948            "java",
18949            "src/EntryPoint.java",
18950            "EntryPoint::handle",
18951            "userService",
18952            "find",
18953            line_of(source, "userService.find()"),
18954        );
18955        let mut cache = DispatchSourceCache::new();
18956
18957        let receiver_type =
18958            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
18959        assert_eq!(receiver_type, "UserService");
18960
18961        let candidates = vec![
18962            method_candidate("audit", "AuditService::find"),
18963            method_candidate("user", "UserService::find"),
18964        ];
18965        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
18966            .expect("type candidate");
18967        assert_eq!(selected.scoped_name, "UserService::find");
18968
18969        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
18970        assert!(
18971            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
18972        );
18973    }
18974
18975    #[test]
18976    fn kotlin_property_and_local_value_types_are_inferred() {
18977        let source = r#"class Handler {
18978    private val auditService: AuditService = AuditService()
18979
18980    fun handle() {
18981        auditService.find()
18982        val userService: UserService = UserService()
18983        userService.find()
18984        val billingService = BillingService()
18985        billingService.find()
18986    }
18987}
18988
18989class UserService { fun find() {} }
18990class AuditService { fun find() {} }
18991class BillingService { fun find() {} }
18992"#;
18993        let dir = tempdir().expect("temp dir");
18994        let root = dir.path();
18995        write_fixture(root, "src/Handler.kt", source);
18996        let mut cache = DispatchSourceCache::new();
18997
18998        let audit_ref = reference(
18999            "kotlin",
19000            "src/Handler.kt",
19001            "Handler::handle",
19002            "auditService",
19003            "find",
19004            line_of(source, "auditService.find()"),
19005        );
19006        assert_eq!(
19007            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
19008            Some("AuditService")
19009        );
19010
19011        let user_ref = reference(
19012            "kotlin",
19013            "src/Handler.kt",
19014            "Handler::handle",
19015            "userService",
19016            "find",
19017            line_of(source, "userService.find()"),
19018        );
19019        assert_eq!(
19020            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
19021            Some("UserService")
19022        );
19023
19024        let billing_ref = reference(
19025            "kotlin",
19026            "src/Handler.kt",
19027            "Handler::handle",
19028            "billingService",
19029            "find",
19030            line_of(source, "billingService.find()"),
19031        );
19032        assert_eq!(
19033            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
19034            Some("BillingService")
19035        );
19036    }
19037
19038    #[test]
19039    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
19040        let source = r#"struct Foo { void run(); };
19041struct PointerFoo { void run(); };
19042struct FactoryFoo { void run(); };
19043FactoryFoo makeFactoryFoo();
19044
19045void handle() {
19046    Foo foo;
19047    foo.run();
19048    PointerFoo* pointerFoo = nullptr;
19049    pointerFoo->run();
19050    auto factoryFoo = makeFactoryFoo();
19051    factoryFoo.run();
19052}
19053"#;
19054        let dir = tempdir().expect("temp dir");
19055        let root = dir.path();
19056        write_fixture(root, "src/fixture.cpp", source);
19057        let mut cache = DispatchSourceCache::new();
19058
19059        let foo_ref = reference(
19060            "cpp",
19061            "src/fixture.cpp",
19062            "handle",
19063            "foo",
19064            "run",
19065            line_of(source, "foo.run()"),
19066        );
19067        assert_eq!(
19068            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
19069            Some("Foo")
19070        );
19071
19072        let pointer_ref = reference(
19073            "cpp",
19074            "src/fixture.cpp",
19075            "handle",
19076            "pointerFoo",
19077            "run",
19078            line_of(source, "pointerFoo->run()"),
19079        );
19080        assert_eq!(
19081            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
19082            Some("PointerFoo")
19083        );
19084
19085        let factory_ref = reference(
19086            "cpp",
19087            "src/fixture.cpp",
19088            "handle",
19089            "factoryFoo",
19090            "run",
19091            line_of(source, "factoryFoo.run()"),
19092        );
19093        assert_eq!(
19094            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
19095            Some("FactoryFoo")
19096        );
19097    }
19098
19099    #[test]
19100    fn rust_direct_self_field_name_trims_separator_whitespace() {
19101        for receiver_expression in ["self .engine", "self. engine", "self . engine"] {
19102            assert_eq!(
19103                rust_direct_self_field_name(receiver_expression),
19104                Some("engine")
19105            );
19106        }
19107    }
19108
19109    #[test]
19110    fn rust_direct_self_field_receiver_type_is_conservative() {
19111        let source = r#"struct Engine;
19112
19113struct Car {
19114    engine: Engine,
19115}
19116
19117impl Car {
19118    fn run(&self) {
19119        self.engine.start();
19120    }
19121}
19122
19123struct NestedCar {
19124    engine: Engine,
19125}
19126
19127impl NestedCar {
19128    fn run(&self) {
19129        self.inner.engine.start();
19130    }
19131}
19132
19133struct WrappedCar {
19134    engine: Option<Engine>,
19135}
19136
19137impl WrappedCar {
19138    fn run(&self) {
19139        self.engine.start(); // wrapped
19140    }
19141}
19142
19143struct GenericCar<T> {
19144    engine: T,
19145}
19146
19147impl<T> GenericCar<T> {
19148    fn run(&self) {
19149        self.engine.start(); // generic
19150    }
19151}
19152
19153type EngineAlias = Engine;
19154
19155struct AliasCar {
19156    engine: EngineAlias,
19157}
19158
19159impl AliasCar {
19160    fn run(&self) {
19161        self.engine.start(); // alias
19162    }
19163}
19164"#;
19165        let dir = tempdir().expect("temp dir");
19166        let root = dir.path();
19167        write_fixture(root, "src/lib.rs", source);
19168        let mut cache = DispatchSourceCache::new();
19169
19170        let mut direct = reference(
19171            "rust",
19172            "src/lib.rs",
19173            "Car::run",
19174            "engine",
19175            "start",
19176            line_of(source, "self.engine.start()"),
19177        );
19178        direct.receiver_expression = "self.engine".to_string();
19179        assert_eq!(
19180            infer_receiver_type(root, &direct, &mut cache).as_deref(),
19181            Some("Engine")
19182        );
19183
19184        let mut mismatched_impl_target = direct.clone();
19185        mismatched_impl_target.caller_symbol = "other::Car::run".to_string();
19186        assert!(infer_receiver_type(root, &mismatched_impl_target, &mut cache).is_none());
19187
19188        let mut nested = reference(
19189            "rust",
19190            "src/lib.rs",
19191            "NestedCar::run",
19192            "engine",
19193            "start",
19194            line_of(source, "self.inner.engine.start()"),
19195        );
19196        nested.receiver_expression = "self.inner.engine".to_string();
19197        assert!(infer_receiver_type(root, &nested, &mut cache).is_none());
19198
19199        let mut wrapped = reference(
19200            "rust",
19201            "src/lib.rs",
19202            "WrappedCar::run",
19203            "engine",
19204            "start",
19205            line_of(source, "self.engine.start(); // wrapped"),
19206        );
19207        wrapped.receiver_expression = "self.engine".to_string();
19208        assert!(infer_receiver_type(root, &wrapped, &mut cache).is_none());
19209
19210        let mut generic = reference(
19211            "rust",
19212            "src/lib.rs",
19213            "GenericCar::run",
19214            "engine",
19215            "start",
19216            line_of(source, "self.engine.start(); // generic"),
19217        );
19218        generic.receiver_expression = "self.engine".to_string();
19219        assert!(infer_receiver_type(root, &generic, &mut cache).is_none());
19220
19221        let mut alias = reference(
19222            "rust",
19223            "src/lib.rs",
19224            "AliasCar::run",
19225            "engine",
19226            "start",
19227            line_of(source, "self.engine.start(); // alias"),
19228        );
19229        alias.receiver_expression = "self.engine".to_string();
19230        assert!(infer_receiver_type(root, &alias, &mut cache).is_none());
19231    }
19232
19233    #[test]
19234    fn rust_direct_self_reference_field_receiver_is_not_inferred() {
19235        let source = r#"struct Engine;
19236
19237struct Car {
19238    engine: &'static Engine,
19239}
19240
19241impl Car {
19242    fn run(&self) {
19243        self.engine.start();
19244    }
19245}
19246"#;
19247        let dir = tempdir().expect("temp dir");
19248        let root = dir.path();
19249        write_fixture(root, "src/lib.rs", source);
19250        let mut cache = DispatchSourceCache::new();
19251        let mut reference = reference(
19252            "rust",
19253            "src/lib.rs",
19254            "Car::run",
19255            "engine",
19256            "start",
19257            line_of(source, "self.engine.start()"),
19258        );
19259        reference.receiver_expression = "self.engine".to_string();
19260
19261        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19262    }
19263
19264    #[test]
19265    fn rust_trait_impl_self_field_receiver_is_not_inferred() {
19266        let source = r#"trait Drive {
19267    fn run(&self);
19268}
19269
19270struct Engine;
19271
19272struct Car {
19273    engine: Engine,
19274}
19275
19276impl Drive for Car {
19277    fn run(&self) {
19278        self.engine.start();
19279    }
19280}
19281"#;
19282        let dir = tempdir().expect("temp dir");
19283        let root = dir.path();
19284        write_fixture(root, "src/lib.rs", source);
19285        let mut cache = DispatchSourceCache::new();
19286        let mut reference = reference(
19287            "rust",
19288            "src/lib.rs",
19289            "Car::run",
19290            "engine",
19291            "start",
19292            line_of(source, "self.engine.start()"),
19293        );
19294        reference.receiver_expression = "self.engine".to_string();
19295
19296        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19297    }
19298
19299    #[test]
19300    fn rust_self_field_does_not_bind_struct_from_another_module() {
19301        let source = r#"struct Engine;
19302
19303mod unrelated {
19304    struct Car {
19305        engine: Engine,
19306    }
19307}
19308
19309impl Car {
19310    fn run(&self) {
19311        self.engine.start();
19312    }
19313}
19314"#;
19315        let dir = tempdir().expect("temp dir");
19316        let root = dir.path();
19317        write_fixture(root, "src/lib.rs", source);
19318        let mut cache = DispatchSourceCache::new();
19319        let mut reference = reference(
19320            "rust",
19321            "src/lib.rs",
19322            "Car::run",
19323            "engine",
19324            "start",
19325            line_of(source, "self.engine.start()"),
19326        );
19327        reference.receiver_expression = "self.engine".to_string();
19328
19329        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19330    }
19331
19332    #[test]
19333    fn unknown_java_receiver_still_uses_name_match_fallback() {
19334        let source = r#"class EntryPoint {
19335    void handle() {
19336        service.runSpecial();
19337    }
19338}
19339
19340class OnlyService {
19341    void runSpecial() {}
19342}
19343"#;
19344        let dir = tempdir().expect("temp dir");
19345        let root = dir.path();
19346        write_fixture(root, "src/EntryPoint.java", source);
19347        let reference = reference(
19348            "java",
19349            "src/EntryPoint.java",
19350            "EntryPoint::handle",
19351            "service",
19352            "runSpecial",
19353            line_of(source, "service.runSpecial()"),
19354        );
19355        let mut cache = DispatchSourceCache::new();
19356
19357        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
19358        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
19359        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
19360        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
19361    }
19362
19363    fn reference(
19364        lang: &str,
19365        caller_file: &str,
19366        caller_symbol: &str,
19367        receiver: &str,
19368        method_name: &str,
19369        line: u32,
19370    ) -> NameMatchRef {
19371        NameMatchRef {
19372            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
19373            caller_node: format!("{caller_symbol}:node"),
19374            caller_file: caller_file.to_string(),
19375            caller_symbol: caller_symbol.to_string(),
19376            caller_signature: None,
19377            receiver_expression: receiver.to_string(),
19378            receiver: receiver.to_string(),
19379            method_name: method_name.to_string(),
19380            colon_dispatch: false,
19381            line,
19382            lang: lang.to_string(),
19383        }
19384    }
19385
19386    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
19387        NameMatchCandidate {
19388            node_id: node_id.to_string(),
19389            file_path: "src/targets.fixture".to_string(),
19390            scoped_name: scoped_name.to_string(),
19391            kind: "method".to_string(),
19392            start_line: 1,
19393        }
19394    }
19395
19396    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
19397        let path = root.join(rel_path);
19398        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
19399        fs::write(path, source).expect("write fixture");
19400    }
19401
19402    fn line_of(source: &str, needle: &str) -> u32 {
19403        source
19404            .lines()
19405            .position(|line| line.contains(needle))
19406            .map(|index| index as u32 + 1)
19407            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
19408    }
19409}
19410
19411#[cfg(test)]
19412mod bounded_build_breaker_tests {
19413    use super::*;
19414    use crate::build_breaker::{BreakerAdmission, BreakerKey, BuildDeathBreaker, BuildDomain};
19415    use tempfile::tempdir;
19416
19417    #[test]
19418    fn staged_inventory_drives_ordered_bounded_file_batches() {
19419        let temp = tempdir().unwrap();
19420        let root = temp.path().join("root");
19421        std::fs::create_dir_all(&root).unwrap();
19422        let first = root.join("a.ts");
19423        let second = root.join("b.ts");
19424        let third = root.join("c.ts");
19425        for path in [&first, &second, &third] {
19426            std::fs::write(path, "export function item() {}\n").unwrap();
19427        }
19428        let writer_lease = acquire_writer_lease(temp.path(), "inventory-key", &root)
19429            .unwrap()
19430            .expect("test root may write its private staging database");
19431        let store = CallGraphStore::open_at_path(
19432            root.clone(),
19433            "inventory-key".to_string(),
19434            temp.path().join("inventory.sqlite"),
19435            None,
19436            true,
19437            Some(writer_lease),
19438            None,
19439        )
19440        .unwrap()
19441        .store;
19442        let fingerprint = store
19443            .stage_cold_build_file_inventory(&[
19444                third.clone(),
19445                first.clone(),
19446                second.clone(),
19447                first.clone(),
19448            ])
19449            .unwrap();
19450
19451        let conn = store.conn.lock().unwrap();
19452        assert_eq!(
19453            query_count(&conn, "SELECT COUNT(*) FROM staging_file_inventory").unwrap(),
19454            3,
19455            "the primary key deduplicates caller-supplied paths on disk"
19456        );
19457        let first_batch = load_staged_file_batch(&conn, &root, "", 2, u64::MAX)
19458            .unwrap()
19459            .expect("first batch");
19460        assert_eq!(first_batch.paths, vec![first.clone(), second]);
19461        let second_batch =
19462            load_staged_file_batch(&conn, &root, &first_batch.last_path, 2, u64::MAX)
19463                .unwrap()
19464                .expect("second batch");
19465        assert_eq!(second_batch.paths, vec![third]);
19466        assert_eq!(
19467            fingerprint,
19468            callgraph_corpus_fingerprint(&root).unwrap(),
19469            "staged and direct streaming fingerprints agree without walk-order dependence"
19470        );
19471    }
19472
19473    #[test]
19474    fn resumed_stage_preserves_committed_batch_and_counter() {
19475        let temp = tempdir().unwrap();
19476        let root = temp.path().join("root");
19477        std::fs::create_dir_all(&root).unwrap();
19478        let first = root.join("first.ts");
19479        let second = root.join("second.ts");
19480        std::fs::write(&first, "export function first() {}\n").unwrap();
19481        std::fs::write(&second, "export function second() { first(); }\n").unwrap();
19482        let staging = temp.path().join("stage.sqlite");
19483        let writer_lease = acquire_writer_lease(temp.path(), "test-key", &root)
19484            .unwrap()
19485            .expect("test root may write its private staging database");
19486        let store = CallGraphStore::open_at_path(
19487            root.clone(),
19488            "test-key".to_string(),
19489            staging,
19490            None,
19491            true,
19492            Some(writer_lease),
19493            None,
19494        )
19495        .unwrap()
19496        .store;
19497        let corpus_fingerprint = store
19498            .stage_cold_build_file_inventory(&[first.clone(), second.clone()])
19499            .unwrap();
19500        let first_extract = build_file_extract(&root, &first).unwrap();
19501        let first_bytes = first_extract.freshness.size;
19502        {
19503            let mut conn = store.conn.lock().unwrap();
19504            let tx = conn.transaction().unwrap();
19505            clear_tables(&tx).unwrap();
19506            insert_meta(&tx).unwrap();
19507            drop_cold_build_secondary_indexes(&tx).unwrap();
19508            set_meta_ready(&tx, false).unwrap();
19509            set_staged_build_phase(&tx, "extracting").unwrap();
19510            set_staged_string(&tx, STAGED_CORPUS_FINGERPRINT, &corpus_fingerprint).unwrap();
19511            set_staged_u64(&tx, STAGED_COMMITTED_EXTRACTED_BYTES, 0).unwrap();
19512            {
19513                let mut inserts = ColdBuildInsertStatements::new(&tx).unwrap();
19514                insert_file_extract_prepared(
19515                    &mut inserts,
19516                    &root.display().to_string(),
19517                    &first_extract,
19518                )
19519                .unwrap();
19520                for raw in &first_extract.raw_refs {
19521                    insert_staged_ref_prepared(&mut inserts, raw).unwrap();
19522                }
19523            }
19524            increment_staged_extracted_bytes(&tx, first_bytes).unwrap();
19525            tx.commit().unwrap();
19526        }
19527
19528        store
19529            .cold_build_chunked(&[first.clone(), second.clone()], 1)
19530            .unwrap();
19531        let conn = store.conn.lock().unwrap();
19532        assert_eq!(query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(), 2);
19533        assert_eq!(
19534            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19535            first_bytes + std::fs::metadata(second).unwrap().len(),
19536            "the already committed batch and its credit survive adoption; only the new batch increments credit"
19537        );
19538        assert_eq!(staged_build_phase(&conn).unwrap().as_deref(), Some("ready"));
19539    }
19540
19541    const SPECIMEN_CHILD_TEST: &str =
19542        "callgraph_store::bounded_build_breaker_tests::respawn_loop_build_child";
19543    const SPECIMEN_CHILD_ROOT: &str = "AFT_SPECIMEN_CHILD_ROOT";
19544    const SPECIMEN_CHILD_STORE: &str = "AFT_SPECIMEN_CHILD_STORE";
19545    const SPECIMEN_CHILD_PHASE: &str = "AFT_SPECIMEN_CHILD_PHASE";
19546    const SPECIMEN_CHILD_SIGNAL: &str = "AFT_SPECIMEN_CHILD_SIGNAL";
19547
19548    fn wait_for_child_barrier(path: &Path) {
19549        let deadline = Instant::now() + Duration::from_secs(10);
19550        while !path.exists() {
19551            assert!(
19552                Instant::now() < deadline,
19553                "callgraph child did not reach barrier {}",
19554                path.display()
19555            );
19556            std::thread::sleep(Duration::from_millis(5));
19557        }
19558    }
19559
19560    fn spawn_build_child(root: &Path, store: &Path, phase: Option<&str>) -> std::process::Child {
19561        let signal = store.join("specimen-child.reached");
19562        let _ = std::fs::remove_file(&signal);
19563        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
19564        command
19565            .arg("--exact")
19566            .arg(SPECIMEN_CHILD_TEST)
19567            .arg("--nocapture")
19568            .arg("--test-threads=1")
19569            .env(SPECIMEN_CHILD_ROOT, root)
19570            .env(SPECIMEN_CHILD_STORE, store)
19571            .env(SPECIMEN_CHILD_SIGNAL, &signal)
19572            .stdout(std::process::Stdio::null())
19573            .stderr(std::process::Stdio::null());
19574        if let Some(phase) = phase {
19575            command.env(SPECIMEN_CHILD_PHASE, phase);
19576        }
19577        command.spawn().unwrap()
19578    }
19579
19580    fn staging_path(root: &Path, store: &Path) -> PathBuf {
19581        let project_key = crate::search_index::artifact_cache_key(root);
19582        store.join(format!("{project_key}.staging.sqlite.tmp.resume"))
19583    }
19584
19585    fn durable_staging_state(path: &Path) -> (u64, u64) {
19586        if !path.exists() {
19587            return (0, 0);
19588        }
19589        let conn = Connection::open(path).unwrap();
19590        (
19591            query_count(&conn, "SELECT COUNT(*) FROM files").unwrap(),
19592            staged_u64(&conn, STAGED_COMMITTED_EXTRACTED_BYTES).unwrap(),
19593        )
19594    }
19595
19596    fn kill_barrier_child(child: &mut std::process::Child, signal: &Path) {
19597        wait_for_child_barrier(signal);
19598        child.kill().unwrap();
19599        let _ = child.wait().unwrap();
19600    }
19601
19602    #[test]
19603    fn respawn_loop_build_child() {
19604        let Some(root) = std::env::var_os(SPECIMEN_CHILD_ROOT) else {
19605            return;
19606        };
19607        let root = PathBuf::from(root);
19608        let store = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_STORE).unwrap());
19609        if let Some(phase) = std::env::var_os(SPECIMEN_CHILD_PHASE) {
19610            let phase = phase.to_string_lossy().into_owned();
19611            let signal = PathBuf::from(std::env::var_os(SPECIMEN_CHILD_SIGNAL).unwrap());
19612            set_cold_build_phase_observer(Some(Arc::new(move |observed| {
19613                if observed == phase {
19614                    std::fs::write(&signal, observed.as_bytes()).unwrap();
19615                    std::thread::sleep(Duration::from_secs(30));
19616                }
19617            })));
19618        }
19619        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
19620        CallGraphStore::cold_build_with_lease_chunked(store, root, &files, 1).unwrap();
19621    }
19622
19623    #[test]
19624    fn issue_250_respawn_loop_converges_or_trips_without_false_readiness() {
19625        let temp = tempdir().unwrap();
19626        let root = temp.path().join("resumable-root");
19627        let store = temp.path().join("resumable-store");
19628        std::fs::create_dir_all(&root).unwrap();
19629        std::fs::create_dir_all(&store).unwrap();
19630        for index in 0..3 {
19631            std::fs::write(
19632                root.join(format!("file-{index}.ts")),
19633                format!("export function specimen{index}() {{ return {index}; }}\n"),
19634            )
19635            .unwrap();
19636        }
19637        let stage = staging_path(&root, &store);
19638        let signal = store.join("specimen-child.reached");
19639
19640        let mut first = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19641        kill_barrier_child(&mut first, &signal);
19642        let (first_rows, first_bytes) = durable_staging_state(&stage);
19643        assert_eq!(first_rows, 1);
19644        assert!(first_bytes > 0);
19645
19646        let mut second = spawn_build_child(&root, &store, Some("extraction_batch_committed"));
19647        kill_barrier_child(&mut second, &signal);
19648        let (second_rows, second_bytes) = durable_staging_state(&stage);
19649        assert_eq!(second_rows, 2);
19650        assert!(
19651            second_bytes > first_bytes,
19652            "a replacement process must adopt committed bytes instead of restarting from zero"
19653        );
19654
19655        let status = spawn_build_child(&root, &store, None).wait().unwrap();
19656        assert!(status.success(), "uninterrupted replacement build failed");
19657        assert!(!stage.exists(), "published staging file must be renamed");
19658        let ready = CallGraphStore::open_readonly(store.clone(), root.clone())
19659            .unwrap()
19660            .expect("replacement attempts must converge to a published graph");
19661        assert_eq!(ready.indexed_file_count().unwrap(), 3);
19662
19663        let fast_root = temp.path().join("zero-credit-root");
19664        let fast_store = temp.path().join("zero-credit-store");
19665        std::fs::create_dir_all(&fast_root).unwrap();
19666        std::fs::create_dir_all(&fast_store).unwrap();
19667        std::fs::write(
19668            fast_root.join("main.ts"),
19669            "export function neverCommitted() {}\n",
19670        )
19671        .unwrap();
19672        let fast_stage = staging_path(&fast_root, &fast_store);
19673        let fast_signal = fast_store.join("specimen-child.reached");
19674        let breaker_path = fast_store.join("build-breaker.sqlite");
19675        let now = unix_millis_now();
19676
19677        for death in 0..3 {
19678            let mut child = spawn_build_child(&fast_root, &fast_store, Some("enumeration"));
19679            wait_for_child_barrier(&fast_signal);
19680            let attempt_id = Connection::open(&breaker_path)
19681                .unwrap()
19682                .query_row(
19683                    "SELECT attempt_id FROM breaker_attempts
19684                     WHERE death_charged = 0 ORDER BY rowid DESC LIMIT 1",
19685                    [],
19686                    |row| row.get::<_, String>(0),
19687                )
19688                .unwrap();
19689            let (_, committed_bytes) = durable_staging_state(&fast_stage);
19690            assert_eq!(
19691                committed_bytes, 0,
19692                "the fast-kill schedule must not cross an extraction commit"
19693            );
19694            child.kill().unwrap();
19695            let _ = child.wait().unwrap();
19696
19697            let key = BreakerKey::new(
19698                fast_root.display().to_string(),
19699                BuildDomain::CallgraphCold,
19700                callgraph_corpus_fingerprint(&fast_root).unwrap(),
19701            );
19702            BuildDeathBreaker::open(&breaker_path)
19703                .unwrap()
19704                .record_attributed_death_at(&key, &attempt_id, committed_bytes, 0, now + death)
19705                .unwrap();
19706        }
19707
19708        let files = crate::callgraph::walk_project_files(&fast_root).collect::<Vec<_>>();
19709        let suspension = CallGraphStore::cold_build_suspension(&fast_store, &fast_root)
19710            .unwrap()
19711            .expect("three zero-credit process deaths must suspend the root");
19712        assert_eq!(suspension.reason, "zero_credit_death_limit");
19713        assert_eq!(suspension.death_count, 3);
19714        let response = crate::commands::callgraph_store_adapter::suspended_response(
19715            "specimen",
19716            "callers",
19717            &suspension,
19718        );
19719        assert_eq!(response.data["code"], serde_json::json!("build_suspended"));
19720        let message = response.data["message"].as_str().unwrap();
19721        assert!(
19722            message.starts_with("callers: build_suspended domain=callgraph_cold deaths=3 age_ms=")
19723        );
19724        assert!(message.ends_with(
19725            " reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
19726        ));
19727        let refused =
19728            CallGraphStore::cold_build_with_lease_chunked(fast_store, fast_root, &files, 1)
19729                .expect_err("a suspended root must not report a perpetually building worker");
19730        assert!(matches!(refused, CallGraphStoreError::Suspended(_)));
19731    }
19732
19733    #[test]
19734    fn published_callgraph_build_respects_durable_domain_suspension() {
19735        let temp = tempdir().unwrap();
19736        let root = temp.path().join("root");
19737        let store_dir = temp.path().join("store");
19738        std::fs::create_dir_all(&root).unwrap();
19739        let source = root.join("main.ts");
19740        std::fs::write(&source, "export function marker() {}\n").unwrap();
19741        let files = vec![source];
19742        let key = BreakerKey::new(
19743            root.display().to_string(),
19744            BuildDomain::CallgraphCold,
19745            callgraph_corpus_fingerprint(&root).unwrap(),
19746        );
19747        let breaker = BuildDeathBreaker::open(store_dir.join("build-breaker.sqlite")).unwrap();
19748        for _ in 0..3 {
19749            let BreakerAdmission::Admitted(attempt) = breaker.admit(&key, 0).unwrap() else {
19750                panic!("unexpected early suspension");
19751            };
19752            breaker
19753                .record_attributed_death(&key, &attempt.attempt_id, 0, 0)
19754                .unwrap();
19755        }
19756
19757        let error = CallGraphStore::cold_build_with_lease_chunked(store_dir, root, &files, 1)
19758            .expect_err("durably tripped callgraph domain must refuse a new cold build");
19759        assert!(matches!(
19760            error,
19761            CallGraphStoreError::Suspended(ref suspension)
19762                if suspension.domain == BuildDomain::CallgraphCold
19763                    && suspension.death_count == 3
19764        ));
19765    }
19766}