Skip to main content

aft/callgraph_store/
mod.rs

1//! Persistent call/reference graph sidecar.
2//!
3//! Phase 1 intentionally keeps this substrate self-contained: callers can build
4//! and query the sidecar directly, but no runtime command reads from it yet.
5
6use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
7use crate::callgraph::{self, EdgeResolution, FileCallData};
8use crate::error::AftError;
9use crate::imports::{ImportKind, ImportStatement};
10use crate::parser::LangId;
11use crate::symbols::{Range, SymbolKind};
12use rayon::prelude::*;
13use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Statement, Transaction};
14use std::collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20const SCHEMA_VERSION: i64 = 1;
21const BACKEND_TREESITTER: &str = "treesitter";
22const PROVENANCE_TREESITTER: &str = "treesitter+resolver";
23const PROVENANCE_NAME_MATCH: &str = "name_match";
24const PROVENANCE_TYPE_MATCH: &str = "type_match";
25const NAME_MATCH_SCORE_THRESHOLD: f64 = 2.0;
26const TOP_LEVEL_SYMBOL: &str = "<top-level>";
27const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
28
29type ColdBuildSwapObserver = dyn Fn(&Path, &Path) + Send + Sync + 'static;
30// THREAD-LOCAL, not a process-global: the observer fires synchronously on the
31// thread running the cold build, and the only caller (a test) installs and
32// clears it on its own thread. A process-global `Mutex<Option<...>>` raced
33// across parallel tests — one test's installed observer fired during ANOTHER
34// test's `cold_build_with_lease`, asserting against the wrong build's edges
35// (flaked on Windows CI under parallel scheduling). Production never sets it.
36thread_local! {
37    static COLD_BUILD_SWAP_OBSERVER: std::cell::RefCell<Option<Arc<ColdBuildSwapObserver>>> =
38        const { std::cell::RefCell::new(None) };
39}
40
41mod dead_code_projection;
42pub use dead_code_projection::project_dead_code_snapshot;
43
44#[doc(hidden)]
45pub fn set_cold_build_swap_observer(observer: Option<Arc<ColdBuildSwapObserver>>) {
46    COLD_BUILD_SWAP_OBSERVER.with(|slot| *slot.borrow_mut() = observer);
47}
48
49fn notify_cold_build_swap_observer(temp_path: &Path, target_path: &Path) {
50    let observer = COLD_BUILD_SWAP_OBSERVER.with(|slot| slot.borrow().clone());
51    if let Some(observer) = observer {
52        observer(temp_path, target_path);
53    }
54}
55
56#[derive(Debug)]
57pub enum CallGraphStoreError {
58    Io(std::io::Error),
59    Sqlite(rusqlite::Error),
60    Json(serde_json::Error),
61    Aft(AftError),
62    Lock(crate::fs_lock::AcquireError),
63    MissingCallerData { file: String },
64    Unavailable(String),
65    StaleFiles(Vec<String>),
66}
67
68impl fmt::Display for CallGraphStoreError {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::Io(error) => write!(formatter, "I/O error: {error}"),
72            Self::Sqlite(error) => write!(formatter, "sqlite error: {error}"),
73            Self::Json(error) => write!(formatter, "json error: {error}"),
74            Self::Aft(error) => write!(formatter, "callgraph extraction error: {error}"),
75            Self::Lock(error) => write!(formatter, "callgraph build lock error: {error}"),
76            Self::MissingCallerData { file } => {
77                write!(formatter, "missing extracted caller data for {file}")
78            }
79            Self::Unavailable(message) => {
80                write!(formatter, "callgraph store unavailable: {message}")
81            }
82            Self::StaleFiles(files) => {
83                write!(
84                    formatter,
85                    "callgraph store has stale files: {}",
86                    files.join(", ")
87                )
88            }
89        }
90    }
91}
92
93impl std::error::Error for CallGraphStoreError {}
94
95impl From<std::io::Error> for CallGraphStoreError {
96    fn from(error: std::io::Error) -> Self {
97        Self::Io(error)
98    }
99}
100
101impl From<rusqlite::Error> for CallGraphStoreError {
102    fn from(error: rusqlite::Error) -> Self {
103        Self::Sqlite(error)
104    }
105}
106
107impl From<serde_json::Error> for CallGraphStoreError {
108    fn from(error: serde_json::Error) -> Self {
109        Self::Json(error)
110    }
111}
112
113impl From<AftError> for CallGraphStoreError {
114    fn from(error: AftError) -> Self {
115        Self::Aft(error)
116    }
117}
118
119impl From<crate::fs_lock::AcquireError> for CallGraphStoreError {
120    fn from(error: crate::fs_lock::AcquireError) -> Self {
121        Self::Lock(error)
122    }
123}
124
125pub type Result<T> = std::result::Result<T, CallGraphStoreError>;
126
127/// Runtime gate name for Phase-1 callers. The substrate is compiled and tested,
128/// but production commands should only open it through `open_if_enabled` until
129/// Phase 2 migrates consumers.
130pub const CALLGRAPH_STORE_FLAG: &str = "callgraph_store";
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
133pub struct CallGraphStoreOptions {
134    pub enabled: bool,
135}
136
137#[derive(Debug)]
138pub struct CallGraphStore {
139    project_root: PathBuf,
140    project_key: String,
141    /// The concrete on-disk DB file this store opened. With the generation
142    /// scheme this is `<dir>/<key>.g<...>.sqlite` (resolved via the pointer) or,
143    /// for a pre-generation store, the legacy `<dir>/<key>.sqlite`.
144    sqlite_path: PathBuf,
145    /// The generation file NAME this store opened (e.g. `<key>.g<nanos>.<pid>.sqlite`),
146    /// or `None` when it opened the legacy single-file DB. Used to detect when
147    /// another process has published a newer generation so this process can
148    /// drop its connection and reopen (see `current_generation`).
149    generation: Option<String>,
150    conn: Mutex<Connection>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154enum OpenRootRepair {
155    None,
156    ReRooted,
157    NeedsRebuild {
158        previous_roots: Vec<String>,
159        current_root: String,
160        reason: String,
161    },
162}
163
164struct OpenedStore {
165    store: CallGraphStore,
166    root_repair: OpenRootRepair,
167}
168
169#[derive(Debug, Clone)]
170pub struct ColdBuildStats {
171    pub files: usize,
172    pub nodes: usize,
173    pub refs: usize,
174    pub edges: usize,
175    pub failed_files: Vec<String>,
176    pub elapsed_ms: u128,
177}
178
179#[derive(Debug, Clone)]
180pub struct IncrementalStats {
181    pub changed_files: Vec<String>,
182    pub surface_changed: Vec<String>,
183    pub deleted_files: Vec<String>,
184    pub dependency_selected_refs: usize,
185    pub refreshed_own_files: usize,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
189pub struct StoredEdge {
190    pub source_file: String,
191    pub source_symbol: String,
192    pub target_file: String,
193    pub target_symbol: String,
194    pub kind: String,
195    pub line: u32,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct StoreNode {
200    node_id: String,
201    pub file: String,
202    pub symbol: String,
203    pub name: String,
204    pub kind: String,
205    pub line: u32,
206    pub end_line: u32,
207    pub signature: Option<String>,
208    pub exported: bool,
209    pub is_entry_point: bool,
210    pub lang: LangId,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct StoreCallSite {
215    pub caller: StoreNode,
216    pub target_file: String,
217    pub target_symbol: String,
218    pub target: Option<StoreNode>,
219    pub line: u32,
220    pub byte_start: usize,
221    pub byte_end: usize,
222    pub resolved: bool,
223    pub provenance: String,
224}
225
226impl StoreCallSite {
227    pub fn approximate(&self) -> bool {
228        self.provenance == PROVENANCE_NAME_MATCH
229    }
230
231    pub fn resolved_by(&self) -> &str {
232        &self.provenance
233    }
234
235    pub fn supplemental_resolution(&self) -> Option<&str> {
236        match self.provenance.as_str() {
237            PROVENANCE_NAME_MATCH | PROVENANCE_TYPE_MATCH => Some(self.provenance.as_str()),
238            _ => None,
239        }
240    }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct StoreUnresolvedCall {
245    pub caller: StoreNode,
246    pub symbol: String,
247    pub full_ref: Option<String>,
248    pub line: u32,
249    pub byte_start: usize,
250    pub byte_end: usize,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct StoreCallersResult {
255    pub target: StoreNode,
256    pub callers: Vec<StoreCallSite>,
257    pub scanned_files: usize,
258    pub depth_limited: bool,
259    pub truncated: usize,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct StoreImpactCaller {
264    pub site: StoreCallSite,
265    pub signature: Option<String>,
266    pub is_entry_point: bool,
267    pub call_expression: Option<String>,
268    pub parameters: Vec<String>,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct StoreImpactResult {
273    pub target: StoreNode,
274    pub parameters: Vec<String>,
275    pub callers: Vec<StoreImpactCaller>,
276    pub depth_limited: bool,
277    pub truncated: usize,
278}
279
280#[derive(Debug, Clone)]
281struct ExtractFailure {
282    rel_path: String,
283    freshness: Option<FileFreshness>,
284}
285
286#[derive(Debug, Clone)]
287struct BuildExtractsResult {
288    extracts: Vec<FileExtract>,
289    failures: Vec<ExtractFailure>,
290}
291
292#[derive(Debug, Clone)]
293enum StoreForwardCall {
294    Resolved(StoreCallSite),
295    Unresolved(StoreUnresolvedCall),
296}
297
298impl StoreForwardCall {
299    fn byte_start(&self) -> usize {
300        match self {
301            Self::Resolved(site) => site.byte_start,
302            Self::Unresolved(call) => call.byte_start,
303        }
304    }
305
306    fn line(&self) -> u32 {
307        match self {
308            Self::Resolved(site) => site.line,
309            Self::Unresolved(call) => call.line,
310        }
311    }
312}
313
314#[derive(Debug, Clone)]
315struct FileExtract {
316    abs_path: PathBuf,
317    rel_path: String,
318    freshness: FileFreshness,
319    lang: LangId,
320    data: FileCallData,
321    nodes: Vec<NodeRecord>,
322    raw_refs: Vec<RawRef>,
323    dispatch_hints: Vec<DispatchHint>,
324    surface_fingerprint: String,
325}
326
327#[derive(Debug, Clone)]
328struct NodeRecord {
329    id: String,
330    file_path: String,
331    name: String,
332    scoped_name: String,
333    kind: String,
334    range: Range,
335    range_ordinal: u32,
336    signature: Option<String>,
337    exported: bool,
338    is_default_export: bool,
339    is_type_like: bool,
340    is_callgraph_entry_point: bool,
341}
342
343#[derive(Debug, Clone)]
344struct RawRef {
345    ref_id: String,
346    caller_node: Option<String>,
347    caller_symbol: Option<String>,
348    caller_file: String,
349    kind: String,
350    short_name: Option<String>,
351    full_ref: Option<String>,
352    module_path: Option<String>,
353    import_kind: Option<String>,
354    local_name: Option<String>,
355    requested_name: Option<String>,
356    namespace_alias: Option<String>,
357    wildcard: bool,
358    line: u32,
359    byte_start: usize,
360    byte_end: usize,
361    dependencies: BTreeSet<String>,
362}
363
364#[derive(Debug, Clone)]
365struct ResolvedRef {
366    raw: RawRef,
367    status: String,
368    target_node: Option<String>,
369    target_file: Option<String>,
370    target_symbol: Option<String>,
371    dependencies: BTreeSet<String>,
372    edge: Option<EdgeRecord>,
373}
374
375#[derive(Debug, Clone)]
376struct EdgeRecord {
377    edge_id: String,
378    source_node: String,
379    target_node: Option<String>,
380    target_file: String,
381    target_symbol: String,
382    kind: String,
383    line: u32,
384}
385
386#[derive(Debug, Clone)]
387struct DispatchHint {
388    id: String,
389    method_name: String,
390    caller_node: String,
391    file: String,
392    line: u32,
393    byte_start: usize,
394    byte_end: usize,
395}
396
397#[derive(Debug, Clone)]
398struct NameMatchRef {
399    ref_id: String,
400    caller_node: String,
401    caller_file: String,
402    caller_symbol: String,
403    caller_signature: Option<String>,
404    receiver: String,
405    method_name: String,
406    colon_dispatch: bool,
407    line: u32,
408    lang: String,
409}
410
411#[derive(Debug, Clone)]
412struct NameMatchCandidate {
413    node_id: String,
414    file_path: String,
415    scoped_name: String,
416    kind: String,
417}
418
419#[derive(Debug, Clone)]
420struct FileRow {
421    surface_fingerprint: String,
422    freshness: FileFreshness,
423}
424
425#[derive(Debug, Clone)]
426struct DbFileIndex {
427    lang: Option<LangId>,
428    exports: HashSet<String>,
429    default_export: Option<String>,
430    export_aliases: HashMap<String, String>,
431    node_by_scoped: HashMap<String, String>,
432    node_by_bare: HashMap<String, String>,
433    module_targets: HashMap<String, Option<String>>,
434    reexports: Vec<ReexportIndex>,
435}
436
437#[derive(Debug, Clone)]
438struct ReexportIndex {
439    target_file: Option<String>,
440    named: HashMap<String, String>,
441    wildcard: bool,
442}
443
444#[derive(Debug, Clone)]
445struct ProjectIndex<'a> {
446    project_root: PathBuf,
447    files: HashMap<String, DbFileIndex>,
448    caller_data: HashMap<String, &'a FileCallData>,
449    /// Lazily-built `crate_name -> src prefix` map for Rust workspace resolution.
450    /// Built once (whole-tree walk) on first qualified-ref resolution and reused,
451    /// instead of re-walking the project per ref. Skipped entirely when no Rust
452    /// workspace ref is resolved (e.g. warm query path with no Rust changes).
453    workspace_crate_prefixes: std::sync::OnceLock<HashMap<String, String>>,
454}
455
456impl ProjectIndex<'_> {
457    /// Resolve a crate name to its `src` prefix, building the workspace map on
458    /// first use. The map walks the project tree exactly once per index.
459    fn crate_src_prefix(&self, crate_name: &str) -> Option<String> {
460        self.workspace_crate_prefixes
461            .get_or_init(|| build_workspace_crate_prefixes(&self.project_root))
462            .get(crate_name)
463            .cloned()
464    }
465}
466
467impl CallGraphStore {
468    pub fn open_if_enabled(
469        options: CallGraphStoreOptions,
470        callgraph_dir: PathBuf,
471        project_root: PathBuf,
472    ) -> Result<Option<Self>> {
473        if !options.enabled {
474            return Ok(None);
475        }
476        Self::open(callgraph_dir, project_root).map(Some)
477    }
478
479    pub fn open(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Self> {
480        std::fs::create_dir_all(&callgraph_dir)?;
481        let project_key = crate::search_index::project_cache_key(&project_root);
482        // Resolve the current generation via the pointer (falling back to the
483        // legacy single-file DB). If nothing is published yet, open the legacy
484        // path so a brand-new store still gets a writable DB + schema.
485        let (sqlite_path, generation) = resolve_ready_target(&callgraph_dir, &project_key)
486            .unwrap_or_else(|| (legacy_sqlite_path(&callgraph_dir, &project_key), None));
487        let OpenedStore { store, root_repair } = Self::open_at_path(
488            project_root.clone(),
489            project_key,
490            sqlite_path,
491            generation,
492            true,
493        )?;
494        match root_repair {
495            OpenRootRepair::NeedsRebuild { .. } => {
496                log_root_repair_rebuild(&root_repair);
497                drop(store);
498                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
499                let (store, _stats) =
500                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
501                Ok(store)
502            }
503            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(store),
504        }
505    }
506
507    pub fn open_readonly(callgraph_dir: PathBuf, project_root: PathBuf) -> Result<Option<Self>> {
508        let project_key = crate::search_index::project_cache_key(&project_root);
509        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
510        else {
511            return Ok(None);
512        };
513        let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
514        conn.busy_timeout(Duration::from_millis(5_000))?;
515        if !database_ready(&conn).unwrap_or(false) {
516            return Ok(None);
517        }
518        Ok(Some(Self::from_connection(
519            project_root,
520            project_key,
521            sqlite_path,
522            generation,
523            conn,
524        )))
525    }
526
527    /// Open the currently-published ready store with write access so moved-root
528    /// metadata can be repaired before projection readers consume it. Unlike
529    /// [`open`], this preserves the read path's cold/mid-build behavior: if no
530    /// ready generation exists, it returns `Ok(None)` instead of creating an
531    /// empty legacy database. Worktree bridges must keep using [`open_readonly`].
532    pub fn open_ready_repairing(
533        callgraph_dir: PathBuf,
534        project_root: PathBuf,
535    ) -> Result<Option<Self>> {
536        let project_key = crate::search_index::project_cache_key(&project_root);
537        let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
538        else {
539            return Ok(None);
540        };
541        let OpenedStore { store, root_repair } = Self::open_at_path(
542            project_root.clone(),
543            project_key,
544            sqlite_path,
545            generation,
546            true,
547        )?;
548        match root_repair {
549            OpenRootRepair::NeedsRebuild { .. } => {
550                log_root_repair_rebuild(&root_repair);
551                drop(store);
552                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
553                let (store, _stats) =
554                    Self::cold_build_with_lease(callgraph_dir, project_root, &files)?;
555                Ok(Some(store))
556            }
557            OpenRootRepair::None | OpenRootRepair::ReRooted => Ok(Some(store)),
558        }
559    }
560
561    pub fn cold_build_with_lease(
562        callgraph_dir: PathBuf,
563        project_root: PathBuf,
564        files: &[PathBuf],
565    ) -> Result<(Self, ColdBuildStats)> {
566        Self::cold_build_with_lease_chunked(callgraph_dir, project_root, files, 0)
567    }
568
569    pub fn cold_build_with_lease_chunked(
570        callgraph_dir: PathBuf,
571        project_root: PathBuf,
572        files: &[PathBuf],
573        chunk_size: usize,
574    ) -> Result<(Self, ColdBuildStats)> {
575        std::fs::create_dir_all(&callgraph_dir)?;
576        let project_key = crate::search_index::project_cache_key(&project_root);
577        let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
578        let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
579        let (stats, generation) = Self::cold_build_publish_locked(
580            &callgraph_dir,
581            &project_root,
582            &project_key,
583            files,
584            chunk_size,
585        )?;
586        let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
587        Ok((store, stats))
588    }
589
590    pub fn ensure_built_with_lease(
591        callgraph_dir: PathBuf,
592        project_root: PathBuf,
593        files: &[PathBuf],
594    ) -> Result<(Self, Option<ColdBuildStats>)> {
595        Self::ensure_built_with_lease_chunked(callgraph_dir, project_root, files, 0)
596    }
597
598    pub fn ensure_built_with_lease_chunked(
599        callgraph_dir: PathBuf,
600        project_root: PathBuf,
601        files: &[PathBuf],
602        chunk_size: usize,
603    ) -> Result<(Self, Option<ColdBuildStats>)> {
604        std::fs::create_dir_all(&callgraph_dir)?;
605        let project_key = crate::search_index::project_cache_key(&project_root);
606        let lock_path = callgraph_dir.join(format!("{project_key}.build.lock"));
607        let _guard = crate::fs_lock::try_acquire(&lock_path, Duration::from_secs(30))?;
608        // Another process may have published a ready generation while we waited
609        // for the lock — open it instead of rebuilding. If that generation is
610        // from this same project at an older filesystem root, repair the root
611        // metadata in-place while still holding the build lease. If data rows
612        // contain absolute paths, publish a fresh generation under this lease
613        // rather than recursively reacquiring the same lock.
614        if let Some((sqlite_path, generation)) = resolve_ready_target(&callgraph_dir, &project_key)
615        {
616            let OpenedStore { store, root_repair } = Self::open_at_path(
617                project_root.clone(),
618                project_key.clone(),
619                sqlite_path,
620                generation,
621                true,
622            )?;
623            match root_repair {
624                OpenRootRepair::NeedsRebuild { .. } => {
625                    log_root_repair_rebuild(&root_repair);
626                    drop(store);
627                    let (stats, generation) = Self::cold_build_publish_locked(
628                        &callgraph_dir,
629                        &project_root,
630                        &project_key,
631                        files,
632                        chunk_size,
633                    )?;
634                    let store = Self::open_generation(
635                        &callgraph_dir,
636                        project_root,
637                        project_key,
638                        generation,
639                    )?;
640                    return Ok((store, Some(stats)));
641                }
642                OpenRootRepair::None | OpenRootRepair::ReRooted => {
643                    return Ok((store, None));
644                }
645            }
646        }
647        let (stats, generation) = Self::cold_build_publish_locked(
648            &callgraph_dir,
649            &project_root,
650            &project_key,
651            files,
652            chunk_size,
653        )?;
654        let store = Self::open_generation(&callgraph_dir, project_root, project_key, generation)?;
655        Ok((store, Some(stats)))
656    }
657
658    /// Build a fresh DB and publish it as a new generation, then atomically flip
659    /// the `<key>.current` pointer to it. NEVER replaces an open DB file, so it
660    /// succeeds even when other processes hold an older generation open (the
661    /// multi-TUI Windows case). The builder owns the temp + generation files
662    /// exclusively (unique pid+nanos names), so it can rename/replace them
663    /// freely; only the tiny pointer is shared, and only Rust std touches it.
664    ///
665    /// Returns the published generation file name so callers open exactly the
666    /// generation they built (avoiding a race where a concurrent build's flip
667    /// would otherwise reopen a different generation).
668    fn cold_build_publish_locked(
669        callgraph_dir: &Path,
670        project_root: &Path,
671        project_key: &str,
672        files: &[PathBuf],
673        chunk_size: usize,
674    ) -> Result<(ColdBuildStats, String)> {
675        let generation = generation_file_name(project_key);
676        let gen_path = callgraph_dir.join(&generation);
677        let temp_path = callgraph_dir.join(format!(
678            "{generation}.tmp.{}.{}",
679            std::process::id(),
680            now_nanos()
681        ));
682        remove_sqlite_file_set(&temp_path);
683
684        let stats = {
685            let temp_store = Self::open_at_path(
686                project_root.to_path_buf(),
687                project_key.to_string(),
688                temp_path.clone(),
689                None,
690                false,
691            )?
692            .store;
693            let stats = temp_store.cold_build_chunked(files, chunk_size)?;
694            temp_store.prepare_for_atomic_swap()?;
695            stats
696        };
697
698        // Move the finished build to its final generation path. This target is
699        // brand-new and owned by us, so the rename never hits an open file.
700        remove_sqlite_file_set(&gen_path);
701        std::fs::rename(&temp_path, &gen_path)?;
702        remove_sqlite_sidecars(&gen_path);
703
704        notify_cold_build_swap_observer(&temp_path, &gen_path);
705
706        // Atomically publish the new generation, then best-effort GC old ones.
707        publish_pointer(callgraph_dir, project_key, &generation)?;
708        gc_old_generations(callgraph_dir, project_key, &generation);
709        Ok((stats, generation))
710    }
711
712    /// Open a specific just-published generation (read-write, WAL) so a builder
713    /// returns a store pinned to exactly what it built.
714    fn open_generation(
715        callgraph_dir: &Path,
716        project_root: PathBuf,
717        project_key: String,
718        generation: String,
719    ) -> Result<Self> {
720        let gen_path = callgraph_dir.join(&generation);
721        Ok(Self::open_at_path(project_root, project_key, gen_path, Some(generation), true)?.store)
722    }
723
724    pub fn needs_cold_build(callgraph_dir: &Path, project_root: &Path) -> Result<bool> {
725        let project_key = crate::search_index::project_cache_key(project_root);
726        // A cold build is needed unless a ready generation (or ready legacy DB)
727        // is currently published.
728        Ok(resolve_ready_target(callgraph_dir, &project_key).is_none())
729    }
730
731    fn open_at_path(
732        project_root: PathBuf,
733        project_key: String,
734        sqlite_path: PathBuf,
735        generation: Option<String>,
736        use_wal: bool,
737    ) -> Result<OpenedStore> {
738        if let Some(parent) = sqlite_path.parent() {
739            std::fs::create_dir_all(parent)?;
740        }
741        let mut conn = Connection::open(&sqlite_path)?;
742        if use_wal {
743            configure_connection(&conn)?;
744        } else {
745            configure_build_connection(&conn)?;
746        }
747        initialize_schema(&conn)?;
748        let root_repair = reconcile_workspace_roots(&mut conn, &project_root)?;
749        let store = Self::from_connection(project_root, project_key, sqlite_path, generation, conn);
750        Ok(OpenedStore { store, root_repair })
751    }
752
753    fn prepare_for_atomic_swap(&self) -> Result<()> {
754        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
755        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;")?;
756        Ok(())
757    }
758
759    fn from_connection(
760        project_root: PathBuf,
761        project_key: String,
762        sqlite_path: PathBuf,
763        generation: Option<String>,
764        conn: Connection,
765    ) -> Self {
766        Self {
767            project_root,
768            project_key,
769            sqlite_path,
770            generation,
771            conn: Mutex::new(conn),
772        }
773    }
774
775    pub fn project_root(&self) -> &Path {
776        &self.project_root
777    }
778
779    pub fn project_key(&self) -> &str {
780        &self.project_key
781    }
782
783    pub fn sqlite_path(&self) -> &Path {
784        &self.sqlite_path
785    }
786
787    /// True if this store still reflects the currently-published generation.
788    /// Cheap (one small pointer-file read). When false, another process (or a
789    /// local cold rebuild) has published a newer generation and the holder
790    /// should drop this store and reopen via the pointer to converge. A missing
791    /// pointer keeps the current store (legacy DB still valid, or transient).
792    pub fn is_current(&self) -> bool {
793        let Some(dir) = self.sqlite_path.parent() else {
794            return true;
795        };
796        match (read_pointer(dir, &self.project_key), &self.generation) {
797            (Some(published), Some(opened)) => &published == opened,
798            // A generation now supersedes the legacy single-file DB we opened.
799            (Some(_), None) => false,
800            // No pointer: keep serving (legacy DB, or an anomalous pointer
801            // removal where our open generation file is still valid).
802            (None, _) => true,
803        }
804    }
805
806    pub fn cold_build(&self, files: &[PathBuf]) -> Result<ColdBuildStats> {
807        self.cold_build_chunked(files, 0)
808    }
809
810    pub fn cold_build_chunked(
811        &self,
812        files: &[PathBuf],
813        chunk_size: usize,
814    ) -> Result<ColdBuildStats> {
815        let started = Instant::now();
816        let bench = std::env::var("AFT_BENCH_COLD").is_ok();
817        macro_rules! phase {
818            ($label:expr, $t:expr) => {
819                if bench {
820                    eprintln!("  cold_build[{}]: {} ms", $label, $t.elapsed().as_millis());
821                    let _ = std::io::Write::flush(&mut std::io::stderr());
822                }
823            };
824        }
825        let files = normalize_file_list(&self.project_root, files)?;
826
827        if chunk_size == 0 {
828            let t = Instant::now();
829            let build = build_extracts_parallel(&self.project_root, &files);
830            phase!("extract_parallel", t);
831            let extracts = build.extracts;
832            let failures = build.failures;
833            let node_count = extracts.iter().map(|extract| extract.nodes.len()).sum();
834
835            let t = Instant::now();
836            let index = ProjectIndex::from_extracts(&self.project_root, &extracts);
837            phase!("build_index", t);
838            let t = Instant::now();
839            let mut resolved_refs = Vec::new();
840            for extract in &extracts {
841                for raw_ref in &extract.raw_refs {
842                    resolved_refs.push(resolve_ref(raw_ref.clone(), &index)?);
843                }
844            }
845            phase!("resolve_refs", t);
846            let ref_count = resolved_refs.len();
847            let edge_count = resolved_refs
848                .iter()
849                .filter(|item| item.edge.is_some())
850                .count();
851
852            let t = Instant::now();
853            let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
854            let tx = conn.transaction()?;
855            clear_tables(&tx)?;
856            insert_meta(&tx)?;
857            drop_cold_build_secondary_indexes(&tx)?;
858            {
859                let workspace_root = self.project_root.display().to_string();
860                let mut inserts = ColdBuildInsertStatements::new(&tx)?;
861                for extract in &extracts {
862                    insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
863                }
864                for failure in &failures {
865                    insert_backend_state_prepared(
866                        &mut inserts.backend_state,
867                        &workspace_root,
868                        &failure.rel_path,
869                        failure
870                            .freshness
871                            .as_ref()
872                            .map(|freshness| &freshness.content_hash),
873                        "stale",
874                    )?;
875                }
876                for resolved in &resolved_refs {
877                    insert_resolved_ref_prepared(&mut inserts, resolved)?;
878                }
879            }
880            create_cold_build_secondary_indexes(&tx)?;
881            let supplemental_edge_count =
882                insert_method_dispatch_edges(&tx, &self.project_root, None)?;
883            set_meta_ready(&tx, true)?;
884            tx.commit()?;
885            phase!("sqlite_insert", t);
886
887            let elapsed_ms = started.elapsed().as_millis();
888            crate::slog_info!(
889                "perf callgraph_store cold_build: files={} nodes={} refs={} edges={} ms={}",
890                extracts.len(),
891                node_count,
892                ref_count,
893                edge_count + supplemental_edge_count,
894                elapsed_ms
895            );
896            return Ok(ColdBuildStats {
897                files: extracts.len(),
898                nodes: node_count,
899                refs: ref_count,
900                edges: edge_count + supplemental_edge_count,
901                failed_files: failures
902                    .into_iter()
903                    .map(|failure| failure.rel_path)
904                    .collect(),
905                elapsed_ms,
906            });
907        }
908
909        // Chunked implementation: parse and resolve in batches to reduce peak
910        // memory during cold build without changing the persisted graph.
911        let t = Instant::now();
912        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
913        let tx = conn.transaction()?;
914        clear_tables(&tx)?;
915        insert_meta(&tx)?;
916        drop_cold_build_secondary_indexes(&tx)?;
917
918        let mut all_raw_refs = Vec::new();
919        let mut failures = Vec::new();
920        let mut node_count = 0;
921        let mut files_parsed = 0;
922
923        let mut persistent_call_data = Vec::new();
924        let mut file_to_call_data_index = HashMap::new();
925        let mut files_index = HashMap::new();
926
927        let workspace_root = self.project_root.display().to_string();
928
929        {
930            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
931            for chunk in files.chunks(chunk_size) {
932                let build = build_extracts_parallel(&self.project_root, chunk);
933                failures.extend(build.failures.clone());
934
935                for extract in build.extracts {
936                    files_parsed += 1;
937                    node_count += extract.nodes.len();
938                    insert_file_extract_prepared(&mut inserts, &workspace_root, &extract)?;
939
940                    let db_file_index = DbFileIndex::from_extract(&self.project_root, &extract);
941                    files_index.insert(extract.rel_path.clone(), db_file_index);
942
943                    persistent_call_data.push(extract.data);
944                    let idx = persistent_call_data.len() - 1;
945                    file_to_call_data_index.insert(extract.rel_path.clone(), idx);
946
947                    all_raw_refs.push((extract.rel_path, extract.raw_refs));
948                }
949                for failure in &build.failures {
950                    insert_backend_state_prepared(
951                        &mut inserts.backend_state,
952                        &workspace_root,
953                        &failure.rel_path,
954                        failure
955                            .freshness
956                            .as_ref()
957                            .map(|freshness| &freshness.content_hash),
958                        "stale",
959                    )?;
960                }
961            }
962        }
963
964        let mut caller_data = HashMap::new();
965        for (rel_path, idx) in &file_to_call_data_index {
966            caller_data.insert(rel_path.clone(), &persistent_call_data[*idx]);
967        }
968        let indexed_caller_files = files_index.keys().cloned().collect::<BTreeSet<_>>();
969        let index = ProjectIndex::from_parts(&self.project_root, files_index, caller_data);
970
971        let mut resolved_refs = Vec::new();
972        for (_, raw_refs) in all_raw_refs {
973            for raw_ref in raw_refs {
974                resolved_refs.push(resolve_ref(raw_ref, &index)?);
975            }
976        }
977
978        let ref_count = resolved_refs.len();
979        let edge_count = resolved_refs
980            .iter()
981            .filter(|item| item.edge.is_some())
982            .count();
983
984        {
985            let mut inserts = ColdBuildInsertStatements::new(&tx)?;
986            for resolved in &resolved_refs {
987                insert_resolved_ref_prepared(&mut inserts, resolved)?;
988            }
989        }
990        create_cold_build_secondary_indexes(&tx)?;
991        let supplemental_edge_count = insert_method_dispatch_edges_chunked(
992            &tx,
993            &self.project_root,
994            &indexed_caller_files,
995            chunk_size,
996        )?;
997        set_meta_ready(&tx, true)?;
998        tx.commit()?;
999        phase!("sqlite_insert", t);
1000
1001        let elapsed_ms = started.elapsed().as_millis();
1002        crate::slog_info!(
1003            "perf callgraph_store cold_build (chunked): files={} nodes={} refs={} edges={} ms={}",
1004            files_parsed,
1005            node_count,
1006            ref_count,
1007            edge_count + supplemental_edge_count,
1008            elapsed_ms
1009        );
1010        Ok(ColdBuildStats {
1011            files: files_parsed,
1012            nodes: node_count,
1013            refs: ref_count,
1014            edges: edge_count + supplemental_edge_count,
1015            failed_files: failures
1016                .into_iter()
1017                .map(|failure| failure.rel_path)
1018                .collect(),
1019            elapsed_ms,
1020        })
1021    }
1022
1023    pub fn refresh_files(&self, changed_files: &[PathBuf]) -> Result<IncrementalStats> {
1024        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1025        let tx = conn.transaction()?;
1026        ensure_database_ready(&tx)?;
1027        let mut changed = Vec::new();
1028        let mut surface_changed = BTreeSet::new();
1029        let mut deleted = BTreeSet::new();
1030        let mut own_refresh = BTreeSet::new();
1031        let mut selected_ref_ids = BTreeSet::new();
1032        let mut selected_refs_by_caller = BTreeMap::new();
1033        let mut changed_extracts: HashMap<String, FileExtract> = HashMap::new();
1034
1035        for input in changed_files {
1036            let abs_path = normalize_file_path(&self.project_root, input)?;
1037            let rel_path = relative_path(&self.project_root, &abs_path);
1038            changed.push(rel_path.clone());
1039            let old_row = load_file_row(&tx, &rel_path)?;
1040            if !abs_path.exists() {
1041                if old_row.is_some() {
1042                    surface_changed.insert(rel_path.clone());
1043                    deleted.insert(rel_path.clone());
1044                    record_dependent_refs(
1045                        &mut selected_ref_ids,
1046                        &mut selected_refs_by_caller,
1047                        ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1048                    );
1049                    delete_file_rows(&tx, &rel_path)?;
1050                    clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
1051                }
1052                continue;
1053            }
1054
1055            if let Some(row) = &old_row {
1056                match cache_freshness::verify_file(&abs_path, &row.freshness) {
1057                    FreshnessVerdict::HotFresh => continue,
1058                    FreshnessVerdict::ContentFresh {
1059                        new_mtime,
1060                        new_size,
1061                    } => {
1062                        update_file_fresh_metadata(
1063                            &tx,
1064                            &rel_path,
1065                            &row.freshness.content_hash,
1066                            new_mtime,
1067                            new_size,
1068                        )?;
1069                        continue;
1070                    }
1071                    FreshnessVerdict::Deleted => {
1072                        surface_changed.insert(rel_path.clone());
1073                        deleted.insert(rel_path.clone());
1074                        record_dependent_refs(
1075                            &mut selected_ref_ids,
1076                            &mut selected_refs_by_caller,
1077                            ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1078                        );
1079                        delete_file_rows(&tx, &rel_path)?;
1080                        clear_backend_state_for_file(&tx, &self.project_root, &rel_path)?;
1081                        continue;
1082                    }
1083                    FreshnessVerdict::Stale => {}
1084                }
1085            }
1086
1087            let extract = build_file_extract(&self.project_root, &abs_path)?;
1088            let surface_is_changed = old_row
1089                .as_ref()
1090                .map(|row| row.surface_fingerprint != extract.surface_fingerprint)
1091                .unwrap_or(true);
1092            if surface_is_changed {
1093                surface_changed.insert(rel_path.clone());
1094                record_dependent_refs(
1095                    &mut selected_ref_ids,
1096                    &mut selected_refs_by_caller,
1097                    ref_ids_depending_on(&tx, &self.project_root, &rel_path)?,
1098                );
1099            }
1100            own_refresh.insert(rel_path.clone());
1101            delete_file_rows(&tx, &rel_path)?;
1102            insert_file_extract(&tx, &self.project_root, &extract)?;
1103            changed_extracts.insert(rel_path, extract);
1104        }
1105
1106        let dependency_selected_refs = selected_ref_ids.len();
1107        let mut touched_callers: BTreeSet<String> =
1108            selected_refs_by_caller.keys().cloned().collect();
1109        touched_callers.extend(own_refresh.iter().cloned());
1110
1111        let mut caller_extracts: HashMap<String, FileExtract> = HashMap::new();
1112        for rel_path in &touched_callers {
1113            if deleted.contains(rel_path) {
1114                continue;
1115            }
1116            if let Some(extract) = changed_extracts.get(rel_path) {
1117                caller_extracts.insert(rel_path.clone(), extract.clone());
1118                continue;
1119            }
1120            let abs_path = self.project_root.join(rel_path);
1121            if abs_path.exists() {
1122                let extract = build_file_extract(&self.project_root, &abs_path)?;
1123                caller_extracts.insert(rel_path.clone(), extract);
1124            }
1125        }
1126
1127        let dependency_callers = touched_callers
1128            .iter()
1129            .filter(|rel_path| !deleted.contains(*rel_path) && !own_refresh.contains(*rel_path))
1130            .cloned()
1131            .collect::<Vec<_>>();
1132        for rel_path in dependency_callers {
1133            let Some(extract) = caller_extracts.get(&rel_path) else {
1134                continue;
1135            };
1136            if stored_node_ids_match_extract(&tx, &rel_path, extract)? {
1137                continue;
1138            }
1139
1140            own_refresh.insert(rel_path.clone());
1141            delete_file_rows(&tx, &rel_path)?;
1142            insert_file_extract(&tx, &self.project_root, extract)?;
1143        }
1144
1145        let index = ProjectIndex::from_db_and_callers(&tx, &self.project_root, &caller_extracts)?;
1146        for rel_path in &touched_callers {
1147            if deleted.contains(rel_path) {
1148                continue;
1149            }
1150            let Some(extract) = caller_extracts.get(rel_path) else {
1151                continue;
1152            };
1153            if own_refresh.contains(rel_path) {
1154                delete_refs_for_caller(&tx, rel_path)?;
1155                for raw_ref in &extract.raw_refs {
1156                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
1157                    insert_resolved_ref(&tx, &resolved)?;
1158                }
1159                continue;
1160            }
1161
1162            let selected_for_caller = selected_refs_by_caller
1163                .get(rel_path)
1164                .cloned()
1165                .unwrap_or_default();
1166            delete_ref_ids(&tx, &selected_for_caller)?;
1167            for raw_ref in &extract.raw_refs {
1168                if selected_for_caller.contains(&raw_ref.ref_id) {
1169                    let resolved = resolve_ref(raw_ref.clone(), &index)?;
1170                    insert_resolved_ref(&tx, &resolved)?;
1171                }
1172            }
1173        }
1174
1175        delete_method_dispatch_edges_for_callers(&tx, &own_refresh)?;
1176        insert_method_dispatch_edges(&tx, &self.project_root, Some(&own_refresh))?;
1177
1178        tx.commit()?;
1179        Ok(IncrementalStats {
1180            changed_files: changed,
1181            surface_changed: surface_changed.into_iter().collect(),
1182            deleted_files: deleted.into_iter().collect(),
1183            dependency_selected_refs,
1184            refreshed_own_files: own_refresh.len(),
1185        })
1186    }
1187
1188    pub fn refresh_corpus(&self, current_files: &[PathBuf]) -> Result<ColdBuildStats> {
1189        self.cold_build(current_files)
1190    }
1191
1192    pub fn mark_files_stale(&self, files: &[PathBuf]) -> Result<Vec<String>> {
1193        let mut conn = self.conn.lock().expect("callgraph store mutex poisoned");
1194        let tx = conn.transaction()?;
1195        let mut marked = Vec::new();
1196        for path in files {
1197            let abs_path = normalize_file_path(&self.project_root, path)?;
1198            let rel_path = relative_path(&self.project_root, &abs_path);
1199            let freshness = cache_freshness::collect(&abs_path).ok();
1200            mark_backend_state(
1201                &tx,
1202                &self.project_root,
1203                &rel_path,
1204                freshness.as_ref().map(|freshness| &freshness.content_hash),
1205                "stale",
1206            )?;
1207            marked.push(rel_path);
1208        }
1209        tx.commit()?;
1210        marked.sort();
1211        marked.dedup();
1212        Ok(marked)
1213    }
1214
1215    pub fn stale_files(&self) -> Result<Vec<String>> {
1216        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1217        let mut stmt = conn.prepare(
1218            "SELECT DISTINCT file_path FROM backend_file_state
1219             WHERE backend = ?1 AND workspace_root = ?2 AND status = 'stale'
1220             ORDER BY file_path",
1221        )?;
1222        let rows = stmt.query_map(
1223            params![BACKEND_TREESITTER, self.project_root.display().to_string()],
1224            |row| row.get::<_, String>(0),
1225        )?;
1226        rows.collect::<std::result::Result<Vec<_>, _>>()
1227            .map_err(Into::into)
1228    }
1229
1230    pub fn backend_status_for_file(&self, file: &Path) -> Result<Option<String>> {
1231        let rel_path = relative_path(
1232            &self.project_root,
1233            &normalize_file_path(&self.project_root, file)?,
1234        );
1235        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1236        conn.query_row(
1237            "SELECT status FROM backend_file_state
1238             WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3
1239             ORDER BY updated_at DESC LIMIT 1",
1240            params![
1241                BACKEND_TREESITTER,
1242                self.project_root.display().to_string(),
1243                rel_path
1244            ],
1245            |row| row.get(0),
1246        )
1247        .optional()
1248        .map_err(Into::into)
1249    }
1250
1251    pub fn edge_snapshot(&self) -> Result<BTreeSet<StoredEdge>> {
1252        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1253        ensure_database_ready(&conn)?;
1254        edge_snapshot_with_conn(&conn)
1255    }
1256
1257    pub fn indexed_file_count(&self) -> Result<usize> {
1258        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1259        ensure_database_ready(&conn)?;
1260        indexed_file_count(&conn)
1261    }
1262
1263    pub fn node_for(&self, file_rel: &Path, symbol: &str) -> Result<StoreNode> {
1264        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1265        let rel_path = relative_path(&self.project_root, &abs_path);
1266        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1267        ensure_database_ready(&conn)?;
1268        resolve_node_for_rel(&conn, &rel_path, symbol)
1269    }
1270
1271    /// Return all positional nodes matching a legacy symbol query in a file.
1272    ///
1273    /// Consumers that need legacy compatibility can collapse these by
1274    /// `StoreNode::symbol` before deciding whether a query is ambiguous.
1275    pub fn nodes_for(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreNode>> {
1276        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1277        let rel_path = relative_path(&self.project_root, &abs_path);
1278        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1279        ensure_database_ready(&conn)?;
1280        nodes_for_file_matching_symbol(&conn, &rel_path, symbol)
1281    }
1282
1283    /// Return all positional nodes matching a symbol query anywhere in the store.
1284    pub fn nodes_matching(&self, symbol: &str) -> Result<Vec<StoreNode>> {
1285        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1286        ensure_database_ready(&conn)?;
1287        nodes_matching_symbol(&conn, symbol)
1288    }
1289
1290    /// Return direct callers for an already-resolved `(file, scoped_symbol)` tuple.
1291    pub fn direct_callers_of(&self, file_rel: &Path, symbol: &str) -> Result<Vec<StoreCallSite>> {
1292        let abs_path = normalize_file_path(&self.project_root, file_rel)?;
1293        let rel_path = relative_path(&self.project_root, &abs_path);
1294        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1295        ensure_database_ready(&conn)?;
1296        direct_callers_for_tuple(&conn, &rel_path, symbol)
1297    }
1298
1299    pub fn callers_of(
1300        &self,
1301        file_rel: &Path,
1302        symbol: &str,
1303        depth: usize,
1304    ) -> Result<StoreCallersResult> {
1305        let target = self.node_for(file_rel, symbol)?;
1306        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1307        ensure_database_ready(&conn)?;
1308        let effective_depth = depth.max(1);
1309        let mut visited = HashSet::new();
1310        let mut callers = Vec::new();
1311        let mut depth_limited = false;
1312        let mut truncated = 0usize;
1313        collect_callers_recursive(
1314            &conn,
1315            &target.file,
1316            &target.symbol,
1317            effective_depth,
1318            0,
1319            &mut visited,
1320            &mut callers,
1321            &mut depth_limited,
1322            &mut truncated,
1323        )?;
1324        Ok(StoreCallersResult {
1325            target,
1326            callers,
1327            scanned_files: indexed_file_count(&conn)?,
1328            depth_limited,
1329            truncated,
1330        })
1331    }
1332
1333    pub fn impact_of(
1334        &self,
1335        file_rel: &Path,
1336        symbol: &str,
1337        depth: usize,
1338    ) -> Result<StoreImpactResult> {
1339        let callers = self.callers_of(file_rel, symbol, depth)?;
1340        let target_parameters = callers
1341            .target
1342            .signature
1343            .as_deref()
1344            .map(|signature| callgraph::extract_parameters(signature, callers.target.lang))
1345            .unwrap_or_default();
1346        let mut source_lines_by_file: HashMap<String, Option<Vec<String>>> = HashMap::new();
1347        for site in &callers.callers {
1348            source_lines_by_file
1349                .entry(site.caller.file.clone())
1350                .or_insert_with(|| {
1351                    read_trimmed_source_lines(&self.project_root.join(&site.caller.file))
1352                });
1353        }
1354        let enriched = callers
1355            .callers
1356            .iter()
1357            .map(|site| StoreImpactCaller {
1358                site: site.clone(),
1359                signature: site.caller.signature.clone(),
1360                is_entry_point: site.caller.is_entry_point,
1361                call_expression: source_lines_by_file
1362                    .get(&site.caller.file)
1363                    .and_then(|lines| lines.as_ref())
1364                    .and_then(|lines| lines.get(site.line.saturating_sub(1) as usize))
1365                    .cloned(),
1366                parameters: site
1367                    .caller
1368                    .signature
1369                    .as_deref()
1370                    .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
1371                    .unwrap_or_default(),
1372            })
1373            .collect();
1374        Ok(StoreImpactResult {
1375            target: callers.target,
1376            parameters: target_parameters,
1377            callers: enriched,
1378            depth_limited: callers.depth_limited,
1379            truncated: callers.truncated,
1380        })
1381    }
1382
1383    pub fn outgoing_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1384        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1385        ensure_database_ready(&conn)?;
1386        outgoing_calls_for_node(&conn, node)
1387    }
1388
1389    /// Return resolved direct self-call refs suppressed from the general edge table.
1390    pub fn resolved_self_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1391        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1392        ensure_database_ready(&conn)?;
1393        resolved_self_calls_for_node(&conn, node)
1394    }
1395
1396    pub fn unresolved_calls_of(&self, node: &StoreNode) -> Result<Vec<StoreUnresolvedCall>> {
1397        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1398        ensure_database_ready(&conn)?;
1399        unresolved_calls_for_node(&conn, node)
1400    }
1401
1402    pub fn call_tree(
1403        &self,
1404        file_rel: &Path,
1405        symbol: &str,
1406        max_depth: usize,
1407    ) -> Result<callgraph::CallTreeNode> {
1408        let node = self.node_for(file_rel, symbol)?;
1409        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1410        ensure_database_ready(&conn)?;
1411        let mut visited = HashSet::new();
1412        call_tree_inner(&conn, &node, max_depth, 0, &mut visited)
1413    }
1414
1415    pub fn trace_to(
1416        &self,
1417        file_rel: &Path,
1418        symbol: &str,
1419        max_depth: usize,
1420    ) -> Result<callgraph::TraceToResult> {
1421        let target = self.node_for(file_rel, symbol)?;
1422        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1423        ensure_database_ready(&conn)?;
1424        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1425
1426        #[derive(Clone)]
1427        struct PathElem {
1428            node: StoreNode,
1429        }
1430
1431        let initial = vec![PathElem {
1432            node: target.clone(),
1433        }];
1434        let mut complete_paths = Vec::new();
1435        if target.is_entry_point {
1436            complete_paths.push(initial.clone());
1437        }
1438
1439        let mut queue = vec![(initial, 0usize)];
1440        let mut max_depth_reached = false;
1441        let mut truncated_paths = 0usize;
1442
1443        while let Some((path, depth)) = queue.pop() {
1444            if depth >= effective_max {
1445                max_depth_reached = true;
1446                continue;
1447            }
1448            let Some(current) = path.last() else {
1449                continue;
1450            };
1451            let callers =
1452                direct_callers_for_tuple(&conn, &current.node.file, &current.node.symbol)?;
1453            if callers.is_empty() {
1454                if path.len() > 1 {
1455                    truncated_paths += 1;
1456                }
1457                continue;
1458            }
1459
1460            let mut has_new_path = false;
1461            for site in callers {
1462                if path.iter().any(|elem| {
1463                    elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
1464                }) {
1465                    continue;
1466                }
1467                has_new_path = true;
1468                let mut new_path = path.clone();
1469                new_path.push(PathElem {
1470                    node: site.caller.clone(),
1471                });
1472                if site.caller.is_entry_point {
1473                    complete_paths.push(new_path.clone());
1474                }
1475                queue.push((new_path, depth + 1));
1476            }
1477            if !has_new_path && path.len() > 1 {
1478                truncated_paths += 1;
1479            }
1480        }
1481
1482        let mut paths: Vec<callgraph::TracePath> = complete_paths
1483            .into_iter()
1484            .map(|mut elems| {
1485                elems.reverse();
1486                let hops = elems
1487                    .iter()
1488                    .enumerate()
1489                    .map(|(index, elem)| callgraph::TraceHop {
1490                        symbol: elem.node.symbol.clone(),
1491                        file: elem.node.file.clone(),
1492                        line: elem.node.line,
1493                        signature: elem.node.signature.clone(),
1494                        is_entry_point: index == 0 && elem.node.is_entry_point,
1495                    })
1496                    .collect();
1497                callgraph::TracePath { hops }
1498            })
1499            .collect();
1500        paths.sort_by(|left, right| {
1501            let left_entry = left
1502                .hops
1503                .first()
1504                .map(|hop| hop.symbol.as_str())
1505                .unwrap_or("");
1506            let right_entry = right
1507                .hops
1508                .first()
1509                .map(|hop| hop.symbol.as_str())
1510                .unwrap_or("");
1511            left_entry
1512                .cmp(right_entry)
1513                .then(left.hops.len().cmp(&right.hops.len()))
1514        });
1515        let entry_points_found = paths
1516            .iter()
1517            .filter_map(|path| path.hops.first())
1518            .filter(|hop| hop.is_entry_point)
1519            .map(|hop| (hop.file.clone(), hop.symbol.clone()))
1520            .collect::<HashSet<_>>()
1521            .len();
1522
1523        Ok(callgraph::TraceToResult {
1524            target_symbol: target.symbol,
1525            target_file: target.file,
1526            total_paths: paths.len(),
1527            paths,
1528            entry_points_found,
1529            max_depth_reached,
1530            truncated_paths,
1531        })
1532    }
1533
1534    pub fn trace_to_symbol_candidates(
1535        &self,
1536        to_symbol: &str,
1537    ) -> Result<Vec<callgraph::TraceToSymbolCandidate>> {
1538        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1539        ensure_database_ready(&conn)?;
1540        let mut candidates_by_file: HashMap<String, u32> = HashMap::new();
1541        for node in nodes_matching_symbol(&conn, to_symbol)? {
1542            candidates_by_file
1543                .entry(node.file)
1544                .and_modify(|line| *line = (*line).min(node.line))
1545                .or_insert(node.line);
1546        }
1547        let mut candidates: Vec<_> = candidates_by_file
1548            .into_iter()
1549            .map(|(file, line)| callgraph::TraceToSymbolCandidate { file, line })
1550            .collect();
1551        candidates
1552            .sort_by(|left, right| left.file.cmp(&right.file).then(left.line.cmp(&right.line)));
1553        Ok(candidates)
1554    }
1555
1556    pub fn trace_to_symbol(
1557        &self,
1558        file_rel: &Path,
1559        symbol: &str,
1560        to_symbol: &str,
1561        to_file: Option<&Path>,
1562        max_depth: usize,
1563    ) -> Result<callgraph::TraceToSymbolResult> {
1564        let origin = self.node_for(file_rel, symbol)?;
1565        let target_file = to_file
1566            .map(|path| normalize_file_path(&self.project_root, path))
1567            .transpose()?
1568            .map(|path| relative_path(&self.project_root, &path));
1569        let conn = self.conn.lock().expect("callgraph store mutex poisoned");
1570        ensure_database_ready(&conn)?;
1571        let effective_max = if max_depth == 0 {
1572            10
1573        } else {
1574            max_depth.min(16)
1575        };
1576
1577        let start_hop = trace_to_symbol_hop(&origin);
1578        if trace_to_symbol_matches_target(&origin, to_symbol, target_file.as_deref()) {
1579            return Ok(callgraph::TraceToSymbolResult {
1580                path: Some(vec![start_hop]),
1581                complete: true,
1582                reason: None,
1583            });
1584        }
1585
1586        let mut queue = VecDeque::new();
1587        queue.push_back((origin.clone(), vec![start_hop], 0usize));
1588        let mut visited = HashSet::new();
1589        visited.insert((origin.file.clone(), origin.symbol.clone()));
1590        let mut max_depth_exhausted = false;
1591
1592        while let Some((current, path, depth)) = queue.pop_front() {
1593            let callees = outgoing_calls_for_node(&conn, &current)?
1594                .into_iter()
1595                .filter_map(|site| site.target)
1596                .collect::<Vec<_>>();
1597
1598            if depth >= effective_max {
1599                if callees
1600                    .iter()
1601                    .any(|node| !visited.contains(&(node.file.clone(), node.symbol.clone())))
1602                {
1603                    max_depth_exhausted = true;
1604                }
1605                continue;
1606            }
1607
1608            for callee in callees {
1609                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
1610                    continue;
1611                }
1612                let mut next_path = path.clone();
1613                next_path.push(trace_to_symbol_hop(&callee));
1614                if trace_to_symbol_matches_target(&callee, to_symbol, target_file.as_deref()) {
1615                    return Ok(callgraph::TraceToSymbolResult {
1616                        path: Some(next_path),
1617                        complete: true,
1618                        reason: None,
1619                    });
1620                }
1621                queue.push_back((callee, next_path, depth + 1));
1622            }
1623        }
1624
1625        if max_depth_exhausted {
1626            Ok(callgraph::TraceToSymbolResult {
1627                path: None,
1628                complete: false,
1629                reason: Some("max_depth_exhausted".to_string()),
1630            })
1631        } else {
1632            Ok(callgraph::TraceToSymbolResult {
1633                path: None,
1634                complete: true,
1635                reason: Some("no_path_found".to_string()),
1636            })
1637        }
1638    }
1639}
1640
1641fn indexed_file_count(conn: &Connection) -> Result<usize> {
1642    let count: i64 = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
1643    Ok(count.max(0) as usize)
1644}
1645
1646fn resolve_node_for_rel(conn: &Connection, rel_path: &str, symbol: &str) -> Result<StoreNode> {
1647    let candidates = nodes_for_file_matching_symbol(conn, rel_path, symbol)?;
1648    match candidates.as_slice() {
1649        [candidate] => Ok(candidate.clone()),
1650        [] => Err(AftError::SymbolNotFound {
1651            name: symbol.to_string(),
1652            file: rel_path.to_string(),
1653        }
1654        .into()),
1655        _ => Err(AftError::AmbiguousSymbol {
1656            name: symbol.to_string(),
1657            candidates: candidates
1658                .iter()
1659                .map(|candidate| candidate.symbol.clone())
1660                .collect(),
1661        }
1662        .into()),
1663    }
1664}
1665
1666fn nodes_for_file_matching_symbol(
1667    conn: &Connection,
1668    rel_path: &str,
1669    symbol: &str,
1670) -> Result<Vec<StoreNode>> {
1671    let qualified_query = symbol.contains("::");
1672    let sql = if qualified_query {
1673        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1674                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1675         FROM nodes n JOIN files f ON f.path = n.file_path
1676         WHERE n.file_path = ?1 AND n.scoped_name = ?2
1677         ORDER BY n.scoped_name, n.start_line, n.start_col"
1678    } else {
1679        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1680                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1681         FROM nodes n JOIN files f ON f.path = n.file_path
1682         WHERE n.file_path = ?1 AND (n.scoped_name = ?2 OR n.name = ?2)
1683         ORDER BY n.scoped_name, n.start_line, n.start_col"
1684    };
1685    let mut stmt = conn.prepare(sql)?;
1686    let rows = stmt.query_map(params![rel_path, symbol], store_node_from_row)?;
1687    rows.collect::<std::result::Result<Vec<_>, _>>()
1688        .map_err(Into::into)
1689}
1690
1691fn nodes_matching_symbol(conn: &Connection, symbol: &str) -> Result<Vec<StoreNode>> {
1692    let qualified_query = symbol.contains("::");
1693    let sql = if qualified_query {
1694        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1695                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1696         FROM nodes n JOIN files f ON f.path = n.file_path
1697         WHERE n.scoped_name = ?1
1698         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1699    } else {
1700        "SELECT n.id, n.file_path, n.scoped_name, n.name, n.kind, n.start_line, n.end_line,
1701                n.signature, n.exported, n.is_callgraph_entry_point, f.lang
1702         FROM nodes n JOIN files f ON f.path = n.file_path
1703         WHERE n.scoped_name = ?1 OR n.name = ?1
1704         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col"
1705    };
1706    let mut stmt = conn.prepare(sql)?;
1707    let rows = stmt.query_map(params![symbol], store_node_from_row)?;
1708    rows.collect::<std::result::Result<Vec<_>, _>>()
1709        .map_err(Into::into)
1710}
1711
1712fn store_node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreNode> {
1713    store_node_from_row_at(row, 0)
1714}
1715
1716fn store_node_from_row_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<StoreNode> {
1717    let start_line: u32 = row.get::<_, i64>(offset + 5)?.max(0) as u32;
1718    let end_line: u32 = row.get::<_, i64>(offset + 6)?.max(0) as u32;
1719    let lang_label_value: String = row.get(offset + 10)?;
1720    Ok(StoreNode {
1721        node_id: row.get(offset)?,
1722        file: row.get(offset + 1)?,
1723        symbol: row.get(offset + 2)?,
1724        name: row.get(offset + 3)?,
1725        kind: row.get(offset + 4)?,
1726        line: start_line.saturating_add(1),
1727        end_line: end_line.saturating_add(1),
1728        signature: row.get(offset + 7)?,
1729        exported: row.get::<_, i64>(offset + 8)? != 0,
1730        is_entry_point: row.get::<_, i64>(offset + 9)? != 0,
1731        lang: lang_from_label(&lang_label_value).unwrap_or(LangId::TypeScript),
1732    })
1733}
1734
1735fn optional_store_node_from_row_at(
1736    row: &rusqlite::Row<'_>,
1737    offset: usize,
1738) -> rusqlite::Result<Option<StoreNode>> {
1739    if row.get::<_, Option<String>>(offset)?.is_some() {
1740        store_node_from_row_at(row, offset).map(Some)
1741    } else {
1742        Ok(None)
1743    }
1744}
1745
1746#[allow(clippy::too_many_arguments)]
1747fn collect_callers_recursive(
1748    conn: &Connection,
1749    file: &str,
1750    symbol: &str,
1751    max_depth: usize,
1752    current_depth: usize,
1753    visited: &mut HashSet<(String, String)>,
1754    result: &mut Vec<StoreCallSite>,
1755    depth_limited: &mut bool,
1756    truncated: &mut usize,
1757) -> Result<()> {
1758    if current_depth >= max_depth {
1759        let omitted = direct_caller_count_for_tuple(conn, file, symbol)?;
1760        if omitted > 0 {
1761            *depth_limited = true;
1762            *truncated += omitted;
1763        }
1764        return Ok(());
1765    }
1766
1767    if !visited.insert((file.to_string(), symbol.to_string())) {
1768        return Ok(());
1769    }
1770
1771    let sites = direct_callers_for_tuple(conn, file, symbol)?;
1772    for site in sites {
1773        result.push(site.clone());
1774        if current_depth + 1 < max_depth {
1775            collect_callers_recursive(
1776                conn,
1777                &site.caller.file,
1778                &site.caller.symbol,
1779                max_depth,
1780                current_depth + 1,
1781                visited,
1782                result,
1783                depth_limited,
1784                truncated,
1785            )?;
1786        } else {
1787            let omitted =
1788                direct_caller_count_for_tuple(conn, &site.caller.file, &site.caller.symbol)?;
1789            if omitted > 0 {
1790                *depth_limited = true;
1791                *truncated += omitted;
1792            }
1793        }
1794    }
1795    Ok(())
1796}
1797
1798fn direct_caller_count_for_tuple(
1799    conn: &Connection,
1800    target_file: &str,
1801    target_symbol: &str,
1802) -> Result<usize> {
1803    let count: i64 = conn.query_row(
1804        "SELECT COUNT(*)
1805         FROM edges e
1806         JOIN refs r ON r.ref_id = e.ref_id
1807         JOIN nodes src ON src.id = e.source_node
1808         JOIN files src_file ON src_file.path = src.file_path
1809         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2",
1810        params![target_file, target_symbol],
1811        |row| row.get(0),
1812    )?;
1813    Ok(usize::try_from(count).unwrap_or(usize::MAX))
1814}
1815
1816fn direct_callers_for_tuple(
1817    conn: &Connection,
1818    target_file: &str,
1819    target_symbol: &str,
1820) -> Result<Vec<StoreCallSite>> {
1821    let mut stmt = conn.prepare(
1822        "SELECT e.target_file, e.target_symbol, e.line,
1823                r.byte_start, r.byte_end, r.status, e.provenance,
1824                src.id, src.file_path, src.scoped_name, src.name, src.kind, src.start_line,
1825                src.end_line, src.signature, src.exported, src.is_callgraph_entry_point,
1826                src_file.lang,
1827                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1828                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1829                tgt_file.lang
1830         FROM edges e
1831         JOIN refs r ON r.ref_id = e.ref_id
1832         JOIN nodes src ON src.id = e.source_node
1833         JOIN files src_file ON src_file.path = src.file_path
1834         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1835             ON tgt.id = e.target_node
1836         WHERE e.kind = 'call' AND e.target_file = ?1 AND e.target_symbol = ?2
1837         ORDER BY e.source_node, r.byte_start, r.line, r.ref_id",
1838    )?;
1839    let rows = stmt.query_map(params![target_file, target_symbol], |row| {
1840        let caller = store_node_from_row_at(row, 7)?;
1841        let target = optional_store_node_from_row_at(row, 18)?;
1842        Ok(StoreCallSite {
1843            caller,
1844            target_file: row.get(0)?,
1845            target_symbol: row.get(1)?,
1846            target,
1847            line: row.get::<_, i64>(2)?.max(0) as u32,
1848            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1849            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1850            resolved: row.get::<_, String>(5)? == "resolved",
1851            provenance: row.get(6)?,
1852        })
1853    })?;
1854    rows.collect::<std::result::Result<Vec<_>, _>>()
1855        .map_err(Into::into)
1856}
1857
1858fn outgoing_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1859    let mut stmt = conn.prepare(
1860        "SELECT e.target_file, e.target_symbol, e.line,
1861                r.byte_start, r.byte_end, r.status, e.provenance,
1862                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1863                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1864                tgt_file.lang
1865         FROM edges e
1866         JOIN refs r ON r.ref_id = e.ref_id
1867         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1868             ON tgt.id = e.target_node
1869         WHERE e.kind = 'call' AND e.source_node = ?1
1870         ORDER BY r.byte_start, r.line, r.ref_id",
1871    )?;
1872    let rows = stmt.query_map(params![node.node_id], |row| {
1873        let target = optional_store_node_from_row_at(row, 7)?;
1874        Ok(StoreCallSite {
1875            caller: node.clone(),
1876            target_file: row.get(0)?,
1877            target_symbol: row.get(1)?,
1878            target,
1879            line: row.get::<_, i64>(2)?.max(0) as u32,
1880            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1881            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1882            resolved: row.get::<_, String>(5)? == "resolved",
1883            provenance: row.get(6)?,
1884        })
1885    })?;
1886    rows.collect::<std::result::Result<Vec<_>, _>>()
1887        .map_err(Into::into)
1888}
1889
1890fn resolved_self_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreCallSite>> {
1891    let mut stmt = conn.prepare(
1892        "SELECT r.target_file, r.target_symbol, r.line,
1893                r.byte_start, r.byte_end, r.status, r.provenance,
1894                tgt.id, tgt.file_path, tgt.scoped_name, tgt.name, tgt.kind, tgt.start_line,
1895                tgt.end_line, tgt.signature, tgt.exported, tgt.is_callgraph_entry_point,
1896                tgt_file.lang
1897         FROM refs r
1898         LEFT JOIN (nodes tgt JOIN files tgt_file ON tgt_file.path = tgt.file_path)
1899             ON tgt.id = r.target_node
1900         WHERE r.caller_node = ?1
1901           AND r.kind = 'call'
1902           AND r.status <> 'unresolved'
1903           AND r.target_file = ?2
1904           AND r.target_symbol = ?3
1905           AND r.provenance = ?4
1906           AND NOT EXISTS (
1907               SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
1908           )
1909         ORDER BY r.byte_start, r.line, r.ref_id",
1910    )?;
1911    let rows = stmt.query_map(
1912        params![
1913            &node.node_id,
1914            &node.file,
1915            &node.symbol,
1916            PROVENANCE_TREESITTER
1917        ],
1918        |row| {
1919            let target = optional_store_node_from_row_at(row, 7)?;
1920            Ok(StoreCallSite {
1921                caller: node.clone(),
1922                target_file: row.get(0)?,
1923                target_symbol: row.get(1)?,
1924                target,
1925                line: row.get::<_, i64>(2)?.max(0) as u32,
1926                byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1927                byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1928                resolved: row.get::<_, String>(5)? == "resolved",
1929                provenance: row.get(6)?,
1930            })
1931        },
1932    )?;
1933    rows.collect::<std::result::Result<Vec<_>, _>>()
1934        .map_err(Into::into)
1935}
1936
1937fn unresolved_calls_for_node(
1938    conn: &Connection,
1939    node: &StoreNode,
1940) -> Result<Vec<StoreUnresolvedCall>> {
1941    let mut stmt = conn.prepare(
1942        "SELECT COALESCE(short_name, full_ref, ''), full_ref, line, byte_start, byte_end
1943         FROM refs
1944         WHERE caller_node = ?1
1945           AND kind = 'call'
1946           AND status = 'unresolved'
1947           AND NOT EXISTS (
1948               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
1949           )
1950         ORDER BY byte_start, line, ref_id",
1951    )?;
1952    let rows = stmt.query_map(params![node.node_id], |row| {
1953        Ok(StoreUnresolvedCall {
1954            caller: node.clone(),
1955            symbol: row.get(0)?,
1956            full_ref: row.get(1)?,
1957            line: row.get::<_, i64>(2)?.max(0) as u32,
1958            byte_start: row.get::<_, i64>(3)?.max(0) as usize,
1959            byte_end: row.get::<_, i64>(4)?.max(0) as usize,
1960        })
1961    })?;
1962    rows.collect::<std::result::Result<Vec<_>, _>>()
1963        .map_err(Into::into)
1964}
1965
1966fn forward_calls_for_node(conn: &Connection, node: &StoreNode) -> Result<Vec<StoreForwardCall>> {
1967    let mut calls = Vec::new();
1968    calls.extend(
1969        outgoing_calls_for_node(conn, node)?
1970            .into_iter()
1971            .map(StoreForwardCall::Resolved),
1972    );
1973    calls.extend(
1974        unresolved_calls_for_node(conn, node)?
1975            .into_iter()
1976            .map(StoreForwardCall::Unresolved),
1977    );
1978    calls.sort_by(|left, right| {
1979        left.byte_start()
1980            .cmp(&right.byte_start())
1981            .then(left.line().cmp(&right.line()))
1982    });
1983    Ok(calls)
1984}
1985
1986fn forward_call_count_for_node(conn: &Connection, node: &StoreNode) -> Result<usize> {
1987    let resolved_count: i64 = conn.query_row(
1988        "SELECT COUNT(*)
1989         FROM edges e
1990         JOIN refs r ON r.ref_id = e.ref_id
1991         WHERE e.kind = 'call' AND e.source_node = ?1",
1992        params![&node.node_id],
1993        |row| row.get(0),
1994    )?;
1995    let unresolved_count: i64 = conn.query_row(
1996        "SELECT COUNT(*)
1997         FROM refs
1998         WHERE caller_node = ?1
1999           AND kind = 'call'
2000           AND status = 'unresolved'
2001           AND NOT EXISTS (
2002               SELECT 1 FROM edges e WHERE e.ref_id = refs.ref_id AND e.kind = 'call'
2003           )",
2004        params![&node.node_id],
2005        |row| row.get(0),
2006    )?;
2007    let total = resolved_count.saturating_add(unresolved_count);
2008    Ok(usize::try_from(total).unwrap_or(usize::MAX))
2009}
2010
2011fn call_tree_inner(
2012    conn: &Connection,
2013    node: &StoreNode,
2014    max_depth: usize,
2015    current_depth: usize,
2016    visited: &mut HashSet<(String, String)>,
2017) -> Result<callgraph::CallTreeNode> {
2018    let visit_key = (node.file.clone(), node.symbol.clone());
2019    if visited.contains(&visit_key) {
2020        return Ok(callgraph::CallTreeNode {
2021            name: node.symbol.clone(),
2022            file: node.file.clone(),
2023            line: node.line,
2024            signature: node.signature.clone(),
2025            resolved: true,
2026            children: Vec::new(),
2027            depth_limited: false,
2028            truncated: 0,
2029        });
2030    }
2031    visited.insert(visit_key.clone());
2032
2033    let mut children = Vec::new();
2034    let mut depth_limited = false;
2035    let mut truncated = 0usize;
2036
2037    if current_depth < max_depth {
2038        let calls = forward_calls_for_node(conn, node)?;
2039        for call in calls {
2040            match call {
2041                StoreForwardCall::Resolved(site) => {
2042                    if let Some(target) = site.target {
2043                        let child =
2044                            call_tree_inner(conn, &target, max_depth, current_depth + 1, visited)?;
2045                        depth_limited |= child.depth_limited;
2046                        truncated += child.truncated;
2047                        children.push(child);
2048                    } else {
2049                        children.push(callgraph::CallTreeNode {
2050                            name: site.target_symbol,
2051                            file: site.target_file,
2052                            line: site.line,
2053                            signature: None,
2054                            resolved: false,
2055                            children: Vec::new(),
2056                            depth_limited: false,
2057                            truncated: 0,
2058                        });
2059                    }
2060                }
2061                StoreForwardCall::Unresolved(call) => {
2062                    children.push(callgraph::CallTreeNode {
2063                        name: call.symbol,
2064                        file: call.caller.file,
2065                        line: call.line,
2066                        signature: None,
2067                        resolved: false,
2068                        children: Vec::new(),
2069                        depth_limited: false,
2070                        truncated: 0,
2071                    });
2072                }
2073            }
2074        }
2075    } else {
2076        truncated = forward_call_count_for_node(conn, node)?;
2077        depth_limited = truncated > 0;
2078    }
2079
2080    visited.remove(&visit_key);
2081    Ok(callgraph::CallTreeNode {
2082        name: node.symbol.clone(),
2083        file: node.file.clone(),
2084        line: node.line,
2085        signature: node.signature.clone(),
2086        resolved: true,
2087        children,
2088        depth_limited,
2089        truncated,
2090    })
2091}
2092
2093fn trace_to_symbol_hop(node: &StoreNode) -> callgraph::TraceToSymbolHop {
2094    callgraph::TraceToSymbolHop {
2095        symbol: node.symbol.clone(),
2096        file: node.file.clone(),
2097        line: node.line,
2098    }
2099}
2100
2101fn trace_to_symbol_matches_target(
2102    node: &StoreNode,
2103    to_symbol: &str,
2104    to_file: Option<&str>,
2105) -> bool {
2106    if !symbol_query_matches(&node.symbol, to_symbol) {
2107        return false;
2108    }
2109    match to_file {
2110        Some(file) => node.file == file,
2111        None => true,
2112    }
2113}
2114
2115fn symbol_query_matches(symbol: &str, query: &str) -> bool {
2116    symbol == query || unqualified_name(symbol) == query
2117}
2118
2119fn read_trimmed_source_lines(path: &Path) -> Option<Vec<String>> {
2120    let source = std::fs::read_to_string(path).ok()?;
2121    Some(source.lines().map(|line| line.trim().to_string()).collect())
2122}
2123
2124#[doc(hidden)]
2125pub fn live_callgraph_edge_snapshot(
2126    project_root: &Path,
2127    files: &[PathBuf],
2128) -> Result<BTreeSet<StoredEdge>> {
2129    let files = normalize_file_list(project_root, files)?;
2130    let mut graph = callgraph::CallGraph::new(project_root.to_path_buf());
2131    let mut file_data = Vec::new();
2132    for file in &files {
2133        let canon = canonicalize_path(file);
2134        let data = graph.build_file(&canon)?.clone();
2135        file_data.push((canon, data));
2136    }
2137
2138    let mut edges = BTreeSet::new();
2139    for (caller_file, data) in &file_data {
2140        for (caller_symbol, call_sites) in &data.calls_by_symbol {
2141            for call_site in call_sites {
2142                let resolution = graph.resolve_cross_file_edge(
2143                    &call_site.full_callee,
2144                    &call_site.callee_name,
2145                    caller_file,
2146                    &data.import_block,
2147                );
2148                let (target_file, target_symbol) = match resolution {
2149                    EdgeResolution::Resolved { file, symbol } => (file, symbol),
2150                    EdgeResolution::Unresolved { callee_name } => {
2151                        if !callgraph::is_bare_callee(&call_site.full_callee, &callee_name) {
2152                            continue;
2153                        }
2154                        let Ok(target_symbol) = callgraph::resolve_symbol_query_in_data(
2155                            data,
2156                            caller_file,
2157                            &callee_name,
2158                        ) else {
2159                            continue;
2160                        };
2161                        (caller_file.clone(), target_symbol)
2162                    }
2163                };
2164                if target_file == *caller_file && target_symbol == *caller_symbol {
2165                    continue;
2166                }
2167                edges.insert(StoredEdge {
2168                    source_file: relative_path(project_root, caller_file),
2169                    source_symbol: caller_symbol.clone(),
2170                    target_file: relative_path(project_root, &target_file),
2171                    target_symbol,
2172                    kind: "call".to_string(),
2173                    line: call_site.line,
2174                });
2175            }
2176        }
2177    }
2178    Ok(edges)
2179}
2180
2181fn configure_connection(conn: &Connection) -> Result<()> {
2182    conn.pragma_update(None, "journal_mode", "WAL")?;
2183    conn.pragma_update(None, "busy_timeout", 5_000)?;
2184    Ok(())
2185}
2186
2187fn configure_build_connection(conn: &Connection) -> Result<()> {
2188    conn.pragma_update(None, "journal_mode", "DELETE")?;
2189    conn.pragma_update(None, "busy_timeout", 5_000)?;
2190    Ok(())
2191}
2192
2193fn initialize_schema(conn: &Connection) -> Result<()> {
2194    conn.execute_batch(
2195        "CREATE TABLE IF NOT EXISTS files (
2196            path                TEXT PRIMARY KEY,
2197            content_hash        TEXT NOT NULL,
2198            mtime_ns            INTEGER NOT NULL,
2199            size                INTEGER NOT NULL,
2200            lang                TEXT NOT NULL,
2201            is_dead_code_root   INTEGER NOT NULL DEFAULT 0,
2202            is_public_api       INTEGER NOT NULL DEFAULT 0,
2203            surface_fingerprint TEXT NOT NULL,
2204            indexed_at          INTEGER NOT NULL
2205        );
2206
2207        CREATE TABLE IF NOT EXISTS nodes (
2208            id                         TEXT PRIMARY KEY,
2209            file_path                  TEXT NOT NULL,
2210            name                       TEXT NOT NULL,
2211            scoped_name                TEXT NOT NULL,
2212            kind                       TEXT NOT NULL,
2213            start_line                 INTEGER NOT NULL,
2214            start_col                  INTEGER NOT NULL,
2215            end_line                   INTEGER NOT NULL,
2216            end_col                    INTEGER NOT NULL,
2217            range_ordinal              INTEGER NOT NULL,
2218            signature                  TEXT,
2219            exported                   INTEGER NOT NULL,
2220            is_default_export          INTEGER NOT NULL,
2221            is_type_like               INTEGER NOT NULL,
2222            is_callgraph_entry_point   INTEGER NOT NULL,
2223            provenance                 TEXT NOT NULL,
2224            UNIQUE(file_path, start_line, start_col, end_line, end_col, range_ordinal)
2225        );
2226        CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2227        CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2228        CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2229
2230        CREATE TABLE IF NOT EXISTS refs (
2231            ref_id          TEXT PRIMARY KEY,
2232            caller_node     TEXT,
2233            caller_file     TEXT NOT NULL,
2234            kind            TEXT NOT NULL,
2235            short_name      TEXT,
2236            full_ref        TEXT,
2237            module_path     TEXT,
2238            import_kind     TEXT,
2239            local_name      TEXT,
2240            requested_name  TEXT,
2241            namespace_alias TEXT,
2242            wildcard        INTEGER NOT NULL DEFAULT 0,
2243            line            INTEGER NOT NULL,
2244            byte_start      INTEGER NOT NULL,
2245            byte_end        INTEGER NOT NULL,
2246            status          TEXT NOT NULL,
2247            target_node     TEXT,
2248            target_file     TEXT,
2249            target_symbol   TEXT,
2250            provenance      TEXT NOT NULL
2251        );
2252        CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2253        CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2254        CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2255        CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2256
2257        CREATE TABLE IF NOT EXISTS file_dependencies (
2258            file_path   TEXT NOT NULL,
2259            dep_file    TEXT NOT NULL,
2260            PRIMARY KEY(file_path, dep_file)
2261        );
2262        CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2263
2264        CREATE TABLE IF NOT EXISTS edges (
2265            edge_id       TEXT PRIMARY KEY,
2266            ref_id        TEXT NOT NULL,
2267            source_node   TEXT NOT NULL,
2268            target_node   TEXT,
2269            target_file   TEXT NOT NULL,
2270            target_symbol TEXT NOT NULL,
2271            kind          TEXT NOT NULL,
2272            line          INTEGER NOT NULL,
2273            provenance    TEXT NOT NULL
2274        );
2275        CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2276        CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2277        CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2278        CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2279
2280        CREATE TABLE IF NOT EXISTS dispatch_hints (
2281            id           TEXT PRIMARY KEY,
2282            method_name  TEXT NOT NULL,
2283            caller_node  TEXT NOT NULL,
2284            file         TEXT NOT NULL,
2285            line         INTEGER NOT NULL,
2286            byte_start   INTEGER NOT NULL,
2287            byte_end     INTEGER NOT NULL,
2288            provenance   TEXT NOT NULL
2289        );
2290        CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2291
2292        CREATE TABLE IF NOT EXISTS type_ref_names (
2293            name TEXT PRIMARY KEY
2294        );
2295
2296        CREATE TABLE IF NOT EXISTS backend_file_state (
2297            backend        TEXT NOT NULL,
2298            workspace_root TEXT NOT NULL,
2299            file_path      TEXT NOT NULL,
2300            content_hash   TEXT NOT NULL,
2301            status         TEXT NOT NULL,
2302            updated_at     INTEGER NOT NULL,
2303            PRIMARY KEY(backend, workspace_root, file_path, content_hash)
2304        );
2305        CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);
2306
2307        CREATE TABLE IF NOT EXISTS meta (
2308            k TEXT PRIMARY KEY,
2309            v TEXT NOT NULL
2310        );",
2311    )?;
2312    insert_meta(conn)?;
2313    Ok(())
2314}
2315
2316fn insert_meta(conn: &Connection) -> Result<()> {
2317    conn.execute(
2318        "INSERT OR REPLACE INTO meta(k, v) VALUES('schema_version', ?1)",
2319        params![SCHEMA_VERSION.to_string()],
2320    )?;
2321    conn.execute(
2322        "INSERT OR REPLACE INTO meta(k, v) VALUES('fingerprint', ?1)",
2323        params![schema_fingerprint()],
2324    )?;
2325    Ok(())
2326}
2327
2328fn set_meta_ready(conn: &Connection, ready: bool) -> Result<()> {
2329    conn.execute(
2330        "INSERT OR REPLACE INTO meta(k, v) VALUES('ready', ?1)",
2331        params![if ready { "1" } else { "0" }],
2332    )?;
2333    Ok(())
2334}
2335
2336fn database_ready(conn: &Connection) -> Result<bool> {
2337    let schema_version: Option<String> = conn
2338        .query_row("SELECT v FROM meta WHERE k = 'schema_version'", [], |row| {
2339            row.get(0)
2340        })
2341        .optional()?;
2342    let fingerprint: Option<String> = conn
2343        .query_row("SELECT v FROM meta WHERE k = 'fingerprint'", [], |row| {
2344            row.get(0)
2345        })
2346        .optional()?;
2347    let ready: Option<String> = conn
2348        .query_row("SELECT v FROM meta WHERE k = 'ready'", [], |row| row.get(0))
2349        .optional()?;
2350
2351    let expected_schema = SCHEMA_VERSION.to_string();
2352    let expected_fingerprint = schema_fingerprint();
2353    Ok(schema_version.as_deref() == Some(expected_schema.as_str())
2354        && fingerprint.as_deref() == Some(expected_fingerprint.as_str())
2355        && ready.as_deref() == Some("1"))
2356}
2357
2358fn ensure_database_ready(conn: &Connection) -> Result<()> {
2359    if database_ready(conn)? {
2360        Ok(())
2361    } else {
2362        Err(CallGraphStoreError::Unavailable(
2363            "database is missing, stale, or mid-build".to_string(),
2364        ))
2365    }
2366}
2367
2368fn schema_fingerprint() -> String {
2369    // Bump the trailing content-version whenever the BUILD OUTPUT changes (new
2370    // edge sources, broader call extraction) even if the table SHAPE is
2371    // unchanged, so existing on-disk stores rebuild and pick up the new edges.
2372    // v6 -> v7-lean: file-level dependency rows and structured refs without raw JSON payloads.
2373    let input = format!("callgraph_store:v{SCHEMA_VERSION}:positional:raw-ref:v7-lean");
2374    hash_to_hex(blake3::hash(input.as_bytes()))
2375}
2376
2377fn clear_tables(tx: &Transaction<'_>) -> Result<()> {
2378    tx.execute_batch(
2379        "DELETE FROM edges;
2380         DELETE FROM file_dependencies;
2381         DELETE FROM refs;
2382         DELETE FROM dispatch_hints;
2383         DELETE FROM type_ref_names;
2384         DELETE FROM backend_file_state;
2385         DELETE FROM nodes;
2386         DELETE FROM files;",
2387    )?;
2388    Ok(())
2389}
2390
2391fn drop_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
2392    tx.execute_batch(
2393        "DROP INDEX IF EXISTS idx_nodes_file;
2394         DROP INDEX IF EXISTS idx_nodes_name;
2395         DROP INDEX IF EXISTS idx_nodes_scoped;
2396         DROP INDEX IF EXISTS idx_refs_short_name;
2397         DROP INDEX IF EXISTS idx_refs_caller_file;
2398         DROP INDEX IF EXISTS idx_refs_caller_node_kind;
2399         DROP INDEX IF EXISTS idx_refs_target_file;
2400         DROP INDEX IF EXISTS idx_file_dependencies_dep_file;
2401         DROP INDEX IF EXISTS idx_edges_source_kind;
2402         DROP INDEX IF EXISTS idx_edges_target_kind;
2403         DROP INDEX IF EXISTS idx_edges_target_file_symbol;
2404         DROP INDEX IF EXISTS idx_edges_ref_id;
2405         DROP INDEX IF EXISTS idx_dispatch_hints_method;
2406         DROP INDEX IF EXISTS idx_backend_file_state_file;",
2407    )?;
2408    Ok(())
2409}
2410
2411fn create_cold_build_secondary_indexes(tx: &Transaction<'_>) -> Result<()> {
2412    tx.execute_batch(
2413        "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
2414         CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
2415         CREATE INDEX IF NOT EXISTS idx_nodes_scoped ON nodes(scoped_name);
2416         CREATE INDEX IF NOT EXISTS idx_refs_short_name ON refs(short_name);
2417         CREATE INDEX IF NOT EXISTS idx_refs_caller_file ON refs(caller_file);
2418         CREATE INDEX IF NOT EXISTS idx_refs_caller_node_kind ON refs(caller_node, kind, status);
2419         CREATE INDEX IF NOT EXISTS idx_refs_target_file ON refs(target_file);
2420         CREATE INDEX IF NOT EXISTS idx_file_dependencies_dep_file ON file_dependencies(dep_file);
2421         CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_node, kind);
2422         CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_node, kind);
2423         CREATE INDEX IF NOT EXISTS idx_edges_target_file_symbol ON edges(target_file, target_symbol, kind);
2424         CREATE INDEX IF NOT EXISTS idx_edges_ref_id ON edges(ref_id, kind);
2425         CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name);
2426         CREATE INDEX IF NOT EXISTS idx_backend_file_state_file ON backend_file_state(file_path, backend);",
2427    )?;
2428    Ok(())
2429}
2430
2431const STORE_DATA_PATH_COLUMNS: &[(&str, &str)] = &[
2432    ("files", "path"),
2433    ("nodes", "file_path"),
2434    ("refs", "caller_file"),
2435    ("refs", "target_file"),
2436    ("file_dependencies", "file_path"),
2437    ("file_dependencies", "dep_file"),
2438    ("edges", "target_file"),
2439    ("dispatch_hints", "file"),
2440    ("backend_file_state", "file_path"),
2441];
2442
2443/// Reconcile `backend_file_state.workspace_root` when the opener's project root
2444/// differs from what is stored. The store key is the git-root commit hash, so
2445/// multiple live checkouts/clones share one on-disk generation.
2446///
2447/// Cheap in-place re-root is only safe when every previously stored root path is
2448/// gone from disk (true move/rename). If any stale root still exists, another
2449/// clone is still alive and rewriting metadata would ping-pong relative rows
2450/// between trees (possibly on different branches). We then return
2451/// [`OpenRootRepair::NeedsRebuild`] so the caller cold-builds for the current
2452/// opener. That can make each clone rebuild on open when they alternate — bounded
2453/// by open frequency — but each rebuild is correct for its opener, unlike silent
2454/// cross-clone corruption.
2455fn reconcile_workspace_roots(conn: &mut Connection, project_root: &Path) -> Result<OpenRootRepair> {
2456    let roots = stored_workspace_roots(conn)?;
2457    let current_root = project_root.display().to_string();
2458    if roots.is_empty() || (roots.len() == 1 && roots[0] == current_root) {
2459        return Ok(OpenRootRepair::None);
2460    }
2461
2462    if let Some(sample) = sample_absolute_data_path(conn)? {
2463        return Ok(OpenRootRepair::NeedsRebuild {
2464            previous_roots: roots,
2465            current_root,
2466            reason: format!("absolute store data path row {sample}"),
2467        });
2468    }
2469
2470    for stored_root in roots.iter() {
2471        if stored_root == &current_root {
2472            continue;
2473        }
2474        if Path::new(stored_root).exists() {
2475            let reason = format!(
2476                "previous root {stored_root} still exists — concurrent clone, rebuilding per-root"
2477            );
2478            return Ok(OpenRootRepair::NeedsRebuild {
2479                previous_roots: roots,
2480                current_root,
2481                reason,
2482            });
2483        }
2484    }
2485
2486    let tx = conn.transaction()?;
2487    tx.execute(
2488        "UPDATE OR IGNORE backend_file_state
2489         SET workspace_root = ?1
2490         WHERE workspace_root <> ?1",
2491        params![&current_root],
2492    )?;
2493    tx.execute(
2494        "DELETE FROM backend_file_state WHERE workspace_root <> ?1",
2495        params![&current_root],
2496    )?;
2497    tx.commit()?;
2498
2499    crate::slog_info!(
2500        "callgraph store re-rooted from {} to {}",
2501        roots.join(", "),
2502        current_root
2503    );
2504    Ok(OpenRootRepair::ReRooted)
2505}
2506
2507fn stored_workspace_roots(conn: &Connection) -> Result<Vec<String>> {
2508    let mut stmt = conn.prepare(
2509        "SELECT DISTINCT workspace_root
2510         FROM backend_file_state
2511         ORDER BY workspace_root",
2512    )?;
2513    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
2514    rows.collect::<std::result::Result<Vec<_>, _>>()
2515        .map_err(Into::into)
2516}
2517
2518fn sample_absolute_data_path(conn: &Connection) -> Result<Option<String>> {
2519    for (table, column) in STORE_DATA_PATH_COLUMNS {
2520        let sql = format!(
2521            "SELECT DISTINCT {column} FROM {table} WHERE {column} IS NOT NULL AND {column} <> ''"
2522        );
2523        let mut stmt = conn.prepare(&sql)?;
2524        let mut rows = stmt.query([])?;
2525        while let Some(row) = rows.next()? {
2526            let value: String = row.get(0)?;
2527            if stored_path_is_absolute(&value) {
2528                return Ok(Some(format!("{table}.{column}={value}")));
2529            }
2530        }
2531    }
2532    Ok(None)
2533}
2534
2535fn stored_path_is_absolute(value: &str) -> bool {
2536    if value.is_empty() {
2537        return false;
2538    }
2539    if Path::new(value).is_absolute() || value.starts_with('/') {
2540        return true;
2541    }
2542    let bytes = value.as_bytes();
2543    if bytes.len() >= 3
2544        && bytes[1] == b':'
2545        && (bytes[2] == b'/' || bytes[2] == b'\\')
2546        && bytes[0].is_ascii_alphabetic()
2547    {
2548        return true;
2549    }
2550    value.starts_with("\\\\") || value.starts_with("//")
2551}
2552
2553fn log_root_repair_rebuild(repair: &OpenRootRepair) {
2554    if let OpenRootRepair::NeedsRebuild {
2555        previous_roots,
2556        current_root,
2557        reason,
2558    } = repair
2559    {
2560        crate::slog_info!(
2561            "callgraph store root mismatch from {} to {} requires cold rebuild: {}",
2562            previous_roots.join(", "),
2563            current_root,
2564            reason
2565        );
2566    }
2567}
2568
2569/// Nanosecond clock used to make temp/generation file names unique.
2570fn now_nanos() -> u128 {
2571    SystemTime::now()
2572        .duration_since(UNIX_EPOCH)
2573        .unwrap_or(Duration::ZERO)
2574        .as_nanos()
2575}
2576
2577/// The pointer file `<dir>/<key>.current`. Its single line names the current
2578/// generation DB file. ONLY Rust std ever opens this file (never SQLite), so it
2579/// can always be atomically replaced via rename even on Windows — Rust opens
2580/// files with `FILE_SHARE_DELETE`, unlike SQLite's Win32 VFS.
2581fn pointer_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2582    callgraph_dir.join(format!("{project_key}.current"))
2583}
2584
2585/// The legacy single-file DB path used before the generation scheme. Still read
2586/// as a fallback so pre-upgrade on-disk stores keep working until the next cold
2587/// build publishes a generation.
2588fn legacy_sqlite_path(callgraph_dir: &Path, project_key: &str) -> PathBuf {
2589    callgraph_dir.join(format!("{project_key}.sqlite"))
2590}
2591
2592/// A fresh, unique generation file NAME: `<key>.g<nanos>.<pid>.sqlite`. Each
2593/// cold build writes a brand-new generation file, so publishing NEVER replaces
2594/// a file another process holds open (the root Windows fix).
2595fn generation_file_name(project_key: &str) -> String {
2596    format!(
2597        "{project_key}.g{}.{}.sqlite",
2598        now_nanos(),
2599        std::process::id()
2600    )
2601}
2602
2603/// Read the pointer; returns the generation file name if present and non-empty.
2604fn read_pointer(callgraph_dir: &Path, project_key: &str) -> Option<String> {
2605    let text = std::fs::read_to_string(pointer_path(callgraph_dir, project_key)).ok()?;
2606    let name = text.trim();
2607    if name.is_empty() {
2608        None
2609    } else {
2610        Some(name.to_string())
2611    }
2612}
2613
2614/// True if the DB at `path` opens and reports ready (schema + fingerprint + the
2615/// `ready` flag). Uses a throwaway read-only connection.
2616fn db_path_ready(path: &Path) -> bool {
2617    (|| -> Result<bool> {
2618        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
2619        conn.busy_timeout(Duration::from_millis(5_000))?;
2620        database_ready(&conn)
2621    })()
2622    .unwrap_or(false)
2623}
2624
2625/// Resolve the DB file a reader/opener should use, returning `(path, generation)`
2626/// where `generation` is `Some(name)` for a pointer-published generation or
2627/// `None` for the legacy single-file DB. Returns `None` when nothing ready is
2628/// published (caller treats that as "needs cold build").
2629///
2630/// Handles the GC race (the pointer names a generation that was just deleted) by
2631/// re-reading the pointer and retrying a few times.
2632fn resolve_ready_target(
2633    callgraph_dir: &Path,
2634    project_key: &str,
2635) -> Option<(PathBuf, Option<String>)> {
2636    for _ in 0..5 {
2637        if let Some(generation) = read_pointer(callgraph_dir, project_key) {
2638            let gen_path = callgraph_dir.join(&generation);
2639            if gen_path.is_file() {
2640                return db_path_ready(&gen_path).then_some((gen_path, Some(generation)));
2641            }
2642            // Pointer names a missing generation (a GC/publish race): re-read the
2643            // pointer and retry rather than failing the reader.
2644            std::thread::sleep(Duration::from_millis(5));
2645            continue;
2646        }
2647        // No pointer: fall back to the legacy single-file DB if it is ready.
2648        let legacy = legacy_sqlite_path(callgraph_dir, project_key);
2649        return (legacy.is_file() && db_path_ready(&legacy)).then_some((legacy, None));
2650    }
2651    None
2652}
2653
2654/// Atomically publish `generation` as the current store by flipping the pointer
2655/// file. Writes a temp file, fsyncs, then renames over the pointer — never
2656/// replacing an open DB file, so it succeeds cross-platform.
2657fn publish_pointer(callgraph_dir: &Path, project_key: &str, generation: &str) -> Result<()> {
2658    let pointer = pointer_path(callgraph_dir, project_key);
2659    let tmp = callgraph_dir.join(format!(
2660        "{project_key}.current.tmp.{}.{}",
2661        std::process::id(),
2662        now_nanos()
2663    ));
2664    {
2665        use std::io::Write as _;
2666        let mut file = std::fs::File::create(&tmp)?;
2667        file.write_all(generation.as_bytes())?;
2668        file.write_all(b"\n")?;
2669        file.sync_all()?;
2670    }
2671    if let Err(error) = std::fs::rename(&tmp, &pointer) {
2672        let _ = std::fs::remove_file(&tmp);
2673        return Err(error.into());
2674    }
2675    Ok(())
2676}
2677
2678/// Best-effort GC of superseded generation files. Never touches the current
2679/// generation; keeps the most-recent previous generation (so an in-flight
2680/// reader that resolved just before a flip can still open it) and a 60s grace
2681/// window for the rest. Deletion failures (e.g. a still-open generation on
2682/// Windows) are ignored and retried on a later build.
2683fn gc_old_generations(callgraph_dir: &Path, project_key: &str, current: &str) {
2684    let grace = Duration::from_secs(60);
2685    let now = SystemTime::now();
2686    let gen_prefix = format!("{project_key}.g");
2687    let tmp_prefixes = [
2688        format!("{project_key}.g"), // generation build temps (<key>.g...sqlite.tmp.*)
2689        format!("{project_key}.current."), // pointer publish temps (<key>.current.tmp.*)
2690        format!("{project_key}.sqlite.tmp."), // legacy-scheme build temps
2691    ];
2692    let Ok(entries) = std::fs::read_dir(callgraph_dir) else {
2693        return;
2694    };
2695    let mut gens: Vec<(PathBuf, SystemTime)> = Vec::new();
2696    for entry in entries.flatten() {
2697        let name = entry.file_name();
2698        let name = name.to_string_lossy();
2699        let mtime = entry
2700            .metadata()
2701            .and_then(|m| m.modified())
2702            .unwrap_or_else(|_| SystemTime::now());
2703        let aged_out = now.duration_since(mtime).unwrap_or(Duration::ZERO) >= grace;
2704
2705        // Orphaned temp files from a crashed build/publish: remove once aged out.
2706        if name.contains(".tmp.") {
2707            if aged_out && tmp_prefixes.iter().any(|p| name.starts_with(p)) {
2708                let _ = std::fs::remove_file(entry.path());
2709            }
2710            continue;
2711        }
2712
2713        // Superseded legacy single-file DB: best-effort delete once a generation
2714        // is published (ignored if another process still holds it open).
2715        if *name == *format!("{project_key}.sqlite") {
2716            remove_sqlite_file_set(&entry.path());
2717            continue;
2718        }
2719
2720        if name.starts_with(&gen_prefix) && name.ends_with(".sqlite") && name != current {
2721            gens.push((entry.path(), mtime));
2722        }
2723    }
2724    // Keep the newest superseded generation as a safety net for readers that
2725    // resolved the pointer just before the flip; GC the rest after the grace
2726    // window. Deletion of a still-open generation (Windows) fails silently and
2727    // is retried on a later build.
2728    gens.sort_by(|a, b| b.1.cmp(&a.1));
2729    for (index, (path, mtime)) in gens.into_iter().enumerate() {
2730        if index == 0 {
2731            continue;
2732        }
2733        if now.duration_since(mtime).unwrap_or(Duration::ZERO) < grace {
2734            continue;
2735        }
2736        remove_sqlite_file_set(&path);
2737    }
2738}
2739
2740fn remove_sqlite_file_set(path: &Path) {
2741    let _ = std::fs::remove_file(path);
2742    remove_sqlite_sidecars(path);
2743}
2744
2745fn remove_sqlite_sidecars(path: &Path) {
2746    let path_text = path.to_string_lossy();
2747    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-wal")));
2748    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-shm")));
2749    let _ = std::fs::remove_file(PathBuf::from(format!("{path_text}-journal")));
2750}
2751
2752/// Bound the cold-build's tree-sitter pass to half the cores (cap 8) instead of
2753/// the global all-cores rayon pool. The store cold-build is the heaviest
2754/// background pass (parse-dominated) and runs on a separate thread off the
2755/// single-threaded request loop; left unbounded it monopolizes every core and
2756/// starves the bridge so interactive tools time out (the same starvation the
2757/// v0.35 embedder and the inspect Tier-2 pool already cap). 8MB worker stacks
2758/// match the main thread, since the extract walks tree-sitter ASTs.
2759fn build_pool_size() -> usize {
2760    std::thread::available_parallelism()
2761        .map(|parallelism| parallelism.get())
2762        .unwrap_or(1)
2763        .div_ceil(2)
2764        .clamp(1, 8)
2765}
2766
2767fn build_extracts_parallel(project_root: &Path, files: &[PathBuf]) -> BuildExtractsResult {
2768    let extract_one = |path: &PathBuf| match build_file_extract(project_root, path) {
2769        Ok(extract) => Ok(extract),
2770        Err(error) => {
2771            let abs_path =
2772                normalize_file_path(project_root, path).unwrap_or_else(|_| path.to_path_buf());
2773            let rel_path = relative_path(project_root, &abs_path);
2774            let freshness = cache_freshness::collect(&abs_path).ok();
2775            log::debug!(
2776                "callgraph store: skipping {} during cold build: {}",
2777                abs_path.display(),
2778                error
2779            );
2780            Err(ExtractFailure {
2781                rel_path,
2782                freshness,
2783            })
2784        }
2785    };
2786
2787    let run = || -> Vec<std::result::Result<FileExtract, ExtractFailure>> {
2788        files.par_iter().map(extract_one).collect()
2789    };
2790
2791    // Run inside a dedicated bounded pool when one builds; fall back to the
2792    // global pool only if the bounded pool can't be constructed.
2793    let results = match rayon::ThreadPoolBuilder::new()
2794        .num_threads(build_pool_size())
2795        .thread_name(|index| format!("aft-callgraph-build-{index}"))
2796        .stack_size(8 * 1024 * 1024)
2797        .build()
2798    {
2799        Ok(pool) => pool.install(run),
2800        Err(error) => {
2801            log::warn!(
2802                "callgraph store: bounded build pool unavailable ({error}); using global pool"
2803            );
2804            run()
2805        }
2806    };
2807
2808    let mut extracts = Vec::new();
2809    let mut failures = Vec::new();
2810    for result in results {
2811        match result {
2812            Ok(extract) => extracts.push(extract),
2813            Err(failure) => failures.push(failure),
2814        }
2815    }
2816    BuildExtractsResult { extracts, failures }
2817}
2818
2819fn collect_source_freshness(path: &Path, source: &str) -> std::io::Result<FileFreshness> {
2820    let metadata = std::fs::metadata(path)?;
2821    let size = metadata.len();
2822    let content_hash = if size > cache_freshness::CONTENT_HASH_SIZE_CAP {
2823        cache_freshness::zero_hash()
2824    } else if source.len() as u64 == size {
2825        cache_freshness::hash_bytes(source.as_bytes())
2826    } else {
2827        cache_freshness::hash_file_if_small(path, size)?.unwrap_or_else(cache_freshness::zero_hash)
2828    };
2829    Ok(FileFreshness {
2830        mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
2831        size,
2832        content_hash,
2833    })
2834}
2835
2836fn build_file_extract(project_root: &Path, path: &Path) -> Result<FileExtract> {
2837    let abs_path = normalize_file_path(project_root, path)?;
2838    let rel_path = relative_path(project_root, &abs_path);
2839    let source = std::fs::read_to_string(&abs_path)?;
2840    let freshness = collect_source_freshness(&abs_path, &source)?;
2841    let data = callgraph::build_file_data_from_source(&abs_path, &source)?;
2842    let lang = data.lang;
2843    let mut nodes = build_node_records(&rel_path, &source, &data)?;
2844    let node_by_scoped: HashMap<String, String> = nodes
2845        .iter()
2846        .map(|node| (node.scoped_name.clone(), node.id.clone()))
2847        .collect();
2848    let import_dependencies =
2849        import_dependencies(project_root, &abs_path, &data.import_block.imports);
2850    let reexports = collect_reexport_refs(project_root, &abs_path, &rel_path, &source);
2851    let source_less_exports = collect_source_less_export_alias_refs(&rel_path, &source);
2852    let mut raw_refs = Vec::new();
2853    raw_refs.extend(build_call_refs(
2854        &rel_path,
2855        &data,
2856        &node_by_scoped,
2857        &import_dependencies,
2858    ));
2859    let line_index = LineIndex::new(&source);
2860    raw_refs.extend(build_import_refs(
2861        project_root,
2862        &abs_path,
2863        &rel_path,
2864        &data.import_block.imports,
2865        &line_index,
2866    ));
2867    let mut surface_parts = reexports.surface_parts;
2868    surface_parts.extend(source_less_exports.surface_parts);
2869    raw_refs.extend(reexports.raw_refs);
2870    raw_refs.extend(source_less_exports.raw_refs);
2871    let dispatch_hints = build_dispatch_hints(&rel_path, &data, &node_by_scoped);
2872    let surface_fingerprint = surface_fingerprint(&mut nodes, &data, &surface_parts);
2873
2874    Ok(FileExtract {
2875        abs_path,
2876        rel_path,
2877        freshness,
2878        lang,
2879        data,
2880        nodes,
2881        raw_refs,
2882        dispatch_hints,
2883        surface_fingerprint,
2884    })
2885}
2886
2887fn build_node_records(
2888    rel_path: &str,
2889    source: &str,
2890    data: &FileCallData,
2891) -> Result<Vec<NodeRecord>> {
2892    let mut records = Vec::new();
2893    let mut ordinal_by_range: BTreeMap<(u32, u32, u32, u32), u32> = BTreeMap::new();
2894    let mut metadata: Vec<_> = data.symbol_metadata.iter().collect();
2895    metadata.sort_by(|(left, _), (right, _)| left.cmp(right));
2896
2897    for (scoped_name, meta) in metadata {
2898        let name = unqualified_name(scoped_name).to_string();
2899        let range = selection_range(source, scoped_name, &name, &meta.range);
2900        let range_key = (
2901            range.start_line,
2902            range.start_col,
2903            range.end_line,
2904            range.end_col,
2905        );
2906        let ordinal = ordinal_by_range.entry(range_key).or_insert(0);
2907        let range_ordinal = *ordinal;
2908        *ordinal += 1;
2909        let id = node_id(rel_path, &range, range_ordinal, scoped_name);
2910        let exported = meta.exported || data.exported_symbols.iter().any(|item| item == &name);
2911        let is_default_export = data
2912            .default_export_symbol
2913            .as_deref()
2914            .map(|default| default == scoped_name || default == name)
2915            .unwrap_or(false);
2916        records.push(NodeRecord {
2917            id,
2918            file_path: rel_path.to_string(),
2919            name: name.clone(),
2920            scoped_name: scoped_name.clone(),
2921            kind: symbol_kind_label(&meta.kind).to_string(),
2922            range,
2923            range_ordinal,
2924            signature: meta.signature.clone(),
2925            exported,
2926            is_default_export,
2927            is_type_like: is_type_like(&meta.kind),
2928            is_callgraph_entry_point: callgraph::is_entry_point(
2929                scoped_name,
2930                &meta.kind,
2931                exported,
2932                data.lang,
2933            ),
2934        });
2935    }
2936
2937    Ok(records)
2938}
2939
2940fn selection_range(source: &str, scoped_name: &str, name: &str, fallback: &Range) -> Range {
2941    if scoped_name == TOP_LEVEL_SYMBOL {
2942        return Range {
2943            start_line: 0,
2944            start_col: 0,
2945            end_line: 0,
2946            end_col: 0,
2947        };
2948    }
2949    let Some(line) = source.lines().nth(fallback.start_line as usize) else {
2950        return fallback.clone();
2951    };
2952    let start_col = fallback.start_col as usize;
2953    let search_start = start_col.min(line.len());
2954    if let Some(offset) = line[search_start..].find(name) {
2955        let col = search_start + offset;
2956        return Range {
2957            start_line: fallback.start_line,
2958            start_col: col as u32,
2959            end_line: fallback.start_line,
2960            end_col: (col + name.len()) as u32,
2961        };
2962    }
2963    if let Some(offset) = line.find(name) {
2964        return Range {
2965            start_line: fallback.start_line,
2966            start_col: offset as u32,
2967            end_line: fallback.start_line,
2968            end_col: (offset + name.len()) as u32,
2969        };
2970    }
2971    Range {
2972        start_line: fallback.start_line,
2973        start_col: fallback.start_col,
2974        end_line: fallback.start_line,
2975        end_col: fallback.start_col.saturating_add(name.len() as u32),
2976    }
2977}
2978
2979fn node_id(rel_path: &str, range: &Range, ordinal: u32, scoped_name: &str) -> String {
2980    if scoped_name == TOP_LEVEL_SYMBOL {
2981        return format!("top:{}", hash_to_hex(blake3::hash(rel_path.as_bytes())));
2982    }
2983    let input = format!(
2984        "{rel_path}:{}:{}:{}:{}:{ordinal}",
2985        range.start_line, range.start_col, range.end_line, range.end_col
2986    );
2987    format!("pos:{}", hash_to_hex(blake3::hash(input.as_bytes())))
2988}
2989
2990fn build_call_refs(
2991    rel_path: &str,
2992    data: &FileCallData,
2993    node_by_scoped: &HashMap<String, String>,
2994    import_dependencies: &BTreeSet<String>,
2995) -> Vec<RawRef> {
2996    let mut refs = Vec::new();
2997    let mut ordinal = 0usize;
2998    let mut symbols: Vec<_> = data.calls_by_symbol.iter().collect();
2999    symbols.sort_by(|(left, _), (right, _)| left.cmp(right));
3000    for (caller_symbol, call_sites) in symbols {
3001        let caller_node = node_by_scoped.get(caller_symbol).cloned();
3002        for call_site in call_sites {
3003            ordinal += 1;
3004            let ref_id = ref_id(&[
3005                rel_path,
3006                "call",
3007                caller_symbol,
3008                &call_site.line.to_string(),
3009                &call_site.byte_start.to_string(),
3010                &call_site.byte_end.to_string(),
3011                &call_site.full_callee,
3012                &ordinal.to_string(),
3013            ]);
3014            refs.push(RawRef {
3015                ref_id,
3016                caller_node: caller_node.clone(),
3017                caller_symbol: Some(caller_symbol.clone()),
3018                caller_file: rel_path.to_string(),
3019                kind: "call".to_string(),
3020                short_name: Some(call_site.callee_name.clone()),
3021                full_ref: Some(call_site.full_callee.clone()),
3022                module_path: None,
3023                import_kind: None,
3024                local_name: Some(call_site.callee_name.clone()),
3025                requested_name: Some(call_site.callee_name.clone()),
3026                namespace_alias: namespace_alias(&call_site.full_callee),
3027                wildcard: false,
3028                line: call_site.line,
3029                byte_start: call_site.byte_start,
3030                byte_end: call_site.byte_end,
3031                dependencies: import_dependencies.clone(),
3032            });
3033        }
3034    }
3035    refs
3036}
3037
3038fn build_import_refs(
3039    project_root: &Path,
3040    abs_path: &Path,
3041    rel_path: &str,
3042    imports: &[ImportStatement],
3043    line_index: &LineIndex,
3044) -> Vec<RawRef> {
3045    let mut refs = Vec::new();
3046    for (index, import) in imports.iter().enumerate() {
3047        let import_kind = import_kind_label(import.kind).to_string();
3048        let local_name = import_local_names(import).join(",");
3049        let requested_name = import_requested_names(import).join(",");
3050        let ref_id = ref_id(&[
3051            rel_path,
3052            "import",
3053            &import.byte_range.start.to_string(),
3054            &import.byte_range.end.to_string(),
3055            &import.module_path,
3056            &index.to_string(),
3057        ]);
3058        refs.push(RawRef {
3059            ref_id,
3060            caller_node: None,
3061            caller_symbol: None,
3062            caller_file: rel_path.to_string(),
3063            kind: "import".to_string(),
3064            short_name: None,
3065            full_ref: Some(import.raw_text.clone()),
3066            module_path: Some(import.module_path.clone()),
3067            import_kind: Some(import_kind),
3068            local_name: empty_to_none(local_name),
3069            requested_name: empty_to_none(requested_name),
3070            namespace_alias: import.namespace_import.clone(),
3071            wildcard: import_is_wildcard(import),
3072            line: line_index.byte_to_line(import.byte_range.start),
3073            byte_start: import.byte_range.start,
3074            byte_end: import.byte_range.end,
3075            dependencies: module_dependencies(project_root, abs_path, &import.module_path),
3076        });
3077    }
3078    refs
3079}
3080
3081#[derive(Debug, Clone)]
3082struct ReexportRefs {
3083    raw_refs: Vec<RawRef>,
3084    surface_parts: Vec<String>,
3085}
3086
3087fn collect_reexport_refs(
3088    project_root: &Path,
3089    abs_path: &Path,
3090    rel_path: &str,
3091    source: &str,
3092) -> ReexportRefs {
3093    let mut raw_refs = Vec::new();
3094    let mut surface_parts = Vec::new();
3095    let mut search_start = 0usize;
3096    let mut ordinal = 0usize;
3097    while let Some(export_offset) = source[search_start..].find("export") {
3098        let start = search_start + export_offset;
3099        let Some(statement_end_offset) = source[start..].find(';') else {
3100            break;
3101        };
3102        let end = start + statement_end_offset + 1;
3103        let statement = &source[start..end];
3104        search_start = end;
3105        if !statement.contains(" from ") || !statement.contains(['\'', '"']) {
3106            continue;
3107        }
3108        let Some(module_path) = quoted_module_path(statement) else {
3109            continue;
3110        };
3111        ordinal += 1;
3112        let wildcard = statement.contains('*');
3113        let line = source[..start]
3114            .bytes()
3115            .filter(|byte| *byte == b'\n')
3116            .count() as u32
3117            + 1;
3118        let ref_id = ref_id(&[
3119            rel_path,
3120            "reexport",
3121            &start.to_string(),
3122            &end.to_string(),
3123            &module_path,
3124            &ordinal.to_string(),
3125        ]);
3126        surface_parts.push(format!("reexport\t{statement}"));
3127        raw_refs.push(RawRef {
3128            ref_id,
3129            caller_node: None,
3130            caller_symbol: None,
3131            caller_file: rel_path.to_string(),
3132            kind: "reexport".to_string(),
3133            short_name: None,
3134            full_ref: Some(statement.to_string()),
3135            module_path: Some(module_path.clone()),
3136            import_kind: Some("reexport".to_string()),
3137            local_name: None,
3138            requested_name: None,
3139            namespace_alias: None,
3140            wildcard,
3141            line,
3142            byte_start: start,
3143            byte_end: end,
3144            dependencies: module_dependencies(project_root, abs_path, &module_path),
3145        });
3146    }
3147    ReexportRefs {
3148        raw_refs,
3149        surface_parts,
3150    }
3151}
3152
3153fn quoted_module_path(statement: &str) -> Option<String> {
3154    let quote = match (statement.find('\''), statement.find('"')) {
3155        (Some(single), Some(double)) if single < double => '\'',
3156        (Some(_), Some(_)) => '"',
3157        (Some(_), None) => '\'',
3158        (None, Some(_)) => '"',
3159        (None, None) => return None,
3160    };
3161    let start = statement.find(quote)? + 1;
3162    let end = statement[start..].find(quote)? + start;
3163    Some(statement[start..end].to_string())
3164}
3165
3166#[derive(Debug, Clone)]
3167struct SourceLessExportRefs {
3168    raw_refs: Vec<RawRef>,
3169    surface_parts: Vec<String>,
3170}
3171
3172fn collect_source_less_export_alias_refs(rel_path: &str, source: &str) -> SourceLessExportRefs {
3173    let mut raw_refs = Vec::new();
3174    let mut surface_parts = Vec::new();
3175    let mut search_start = 0usize;
3176    let mut ordinal = 0usize;
3177    while let Some(export_offset) = source[search_start..].find("export") {
3178        let start = search_start + export_offset;
3179        let Some(statement_end_offset) = source[start..].find(';') else {
3180            break;
3181        };
3182        let end = start + statement_end_offset + 1;
3183        let statement = &source[start..end];
3184        search_start = end;
3185        if statement.contains(" from ") || !statement.contains('{') || !statement.contains('}') {
3186            continue;
3187        }
3188        let aliases = parse_reexport_names(statement);
3189        if aliases.is_empty() {
3190            continue;
3191        }
3192        let line = source[..start]
3193            .bytes()
3194            .filter(|byte| *byte == b'\n')
3195            .count() as u32
3196            + 1;
3197        for (exported, source_symbol) in aliases {
3198            ordinal += 1;
3199            let ref_id = ref_id(&[
3200                rel_path,
3201                "export_alias",
3202                &start.to_string(),
3203                &end.to_string(),
3204                &exported,
3205                &source_symbol,
3206                &ordinal.to_string(),
3207            ]);
3208            surface_parts.push(format!("export_alias\t{source_symbol}\t{exported}"));
3209            raw_refs.push(RawRef {
3210                ref_id,
3211                caller_node: None,
3212                caller_symbol: None,
3213                caller_file: rel_path.to_string(),
3214                kind: "export_alias".to_string(),
3215                short_name: None,
3216                full_ref: Some(statement.to_string()),
3217                module_path: None,
3218                import_kind: Some("export_alias".to_string()),
3219                local_name: Some(exported),
3220                requested_name: Some(source_symbol),
3221                namespace_alias: None,
3222                wildcard: false,
3223                line,
3224                byte_start: start,
3225                byte_end: end,
3226                dependencies: BTreeSet::new(),
3227            });
3228        }
3229    }
3230    SourceLessExportRefs {
3231        raw_refs,
3232        surface_parts,
3233    }
3234}
3235
3236fn build_dispatch_hints(
3237    rel_path: &str,
3238    data: &FileCallData,
3239    node_by_scoped: &HashMap<String, String>,
3240) -> Vec<DispatchHint> {
3241    let mut hints = Vec::new();
3242    let mut ordinal = 0usize;
3243    for (caller_symbol, call_sites) in &data.calls_by_symbol {
3244        let Some(caller_node) = node_by_scoped.get(caller_symbol) else {
3245            continue;
3246        };
3247        for call_site in call_sites {
3248            if !(call_site.full_callee.contains('.') || call_site.full_callee.contains("::")) {
3249                continue;
3250            }
3251            ordinal += 1;
3252            hints.push(DispatchHint {
3253                id: ref_id(&[
3254                    rel_path,
3255                    "dispatch",
3256                    caller_symbol,
3257                    &call_site.line.to_string(),
3258                    &call_site.byte_start.to_string(),
3259                    &call_site.byte_end.to_string(),
3260                    &ordinal.to_string(),
3261                ]),
3262                method_name: call_site.callee_name.clone(),
3263                caller_node: caller_node.clone(),
3264                file: rel_path.to_string(),
3265                line: call_site.line,
3266                byte_start: call_site.byte_start,
3267                byte_end: call_site.byte_end,
3268            });
3269        }
3270    }
3271    hints
3272}
3273
3274fn surface_fingerprint(
3275    nodes: &mut [NodeRecord],
3276    data: &FileCallData,
3277    reexport_parts: &[String],
3278) -> String {
3279    nodes.sort_by(|left, right| {
3280        (left.file_path.as_str(), left.scoped_name.as_str())
3281            .cmp(&(right.file_path.as_str(), right.scoped_name.as_str()))
3282    });
3283    let mut parts = Vec::new();
3284    for node in nodes.iter() {
3285        parts.push(format!(
3286            "node\t{}\t{}\t{}\t{}\t{}:{}:{}:{}:{}\t{}",
3287            node.scoped_name,
3288            node.name,
3289            node.kind,
3290            node.exported,
3291            node.range.start_line,
3292            node.range.start_col,
3293            node.range.end_line,
3294            node.range.end_col,
3295            node.range_ordinal,
3296            node.signature.as_deref().unwrap_or("")
3297        ));
3298    }
3299    let mut exports = data.exported_symbols.clone();
3300    exports.sort();
3301    for export in exports {
3302        parts.push(format!("export\t{export}"));
3303    }
3304    if let Some(default_export) = &data.default_export_symbol {
3305        parts.push(format!("default\t{default_export}"));
3306    }
3307    let mut imports: Vec<String> = data
3308        .import_block
3309        .imports
3310        .iter()
3311        .map(|import| {
3312            format!(
3313                "import\t{}\t{:?}\t{}",
3314                import.module_path, import.form, import.raw_text
3315            )
3316        })
3317        .collect();
3318    imports.sort();
3319    parts.extend(imports);
3320    parts.extend(reexport_parts.iter().cloned());
3321    hash_to_hex(blake3::hash(parts.join("\n").as_bytes()))
3322}
3323
3324fn resolve_ref(raw: RawRef, index: &ProjectIndex<'_>) -> Result<ResolvedRef> {
3325    if raw.kind != "call" {
3326        return Ok(ResolvedRef {
3327            dependencies: raw.dependencies.clone(),
3328            raw,
3329            status: "unresolved".to_string(),
3330            target_node: None,
3331            target_file: None,
3332            target_symbol: None,
3333            edge: None,
3334        });
3335    }
3336
3337    let caller_file = raw.caller_file.clone();
3338    let caller_data = index.caller_data.get(&caller_file).ok_or_else(|| {
3339        CallGraphStoreError::MissingCallerData {
3340            file: caller_file.clone(),
3341        }
3342    })?;
3343    let full_ref = raw.full_ref.as_deref().unwrap_or_default();
3344    let short_name = raw.short_name.as_deref().unwrap_or_default();
3345    let mut dependencies = raw.dependencies.clone();
3346
3347    let resolved = match index.lang_for(&caller_file) {
3348        Some(LangId::Rust) => {
3349            resolve_rust_target(index, &caller_file, full_ref, short_name, caller_data)
3350        }
3351        Some(LangId::TypeScript | LangId::Tsx | LangId::JavaScript) => {
3352            resolve_js_ts_target(index, &caller_file, full_ref, short_name, caller_data)
3353        }
3354        _ => resolve_local_target(index, &caller_file, full_ref, short_name, caller_data),
3355    };
3356
3357    let Some((status, target_file, target_symbol)) = resolved else {
3358        return Ok(ResolvedRef {
3359            raw,
3360            status: "unresolved".to_string(),
3361            target_node: None,
3362            target_file: None,
3363            target_symbol: None,
3364            dependencies,
3365            edge: None,
3366        });
3367    };
3368
3369    dependencies.insert(target_file.clone());
3370    let target_node = index.node_for_symbol(&target_file, &target_symbol);
3371    let source_node = raw.caller_node.clone();
3372    let edge = if let Some(source_node) = source_node {
3373        if target_file == caller_file
3374            && raw.caller_symbol.as_deref() == Some(target_symbol.as_str())
3375        {
3376            None
3377        } else {
3378            Some(EdgeRecord {
3379                edge_id: ref_id(&[&raw.ref_id, "edge"]),
3380                source_node,
3381                target_node: target_node.clone(),
3382                target_file: target_file.clone(),
3383                target_symbol: target_symbol.clone(),
3384                kind: "call".to_string(),
3385                line: raw.line,
3386            })
3387        }
3388    } else {
3389        None
3390    };
3391
3392    Ok(ResolvedRef {
3393        raw,
3394        status,
3395        target_node,
3396        target_file: Some(target_file),
3397        target_symbol: Some(target_symbol),
3398        dependencies,
3399        edge,
3400    })
3401}
3402
3403fn resolve_js_ts_target(
3404    index: &ProjectIndex<'_>,
3405    caller_file: &str,
3406    full_ref: &str,
3407    short_name: &str,
3408    caller_data: &FileCallData,
3409) -> Option<(String, String, String)> {
3410    if let Some((namespace, member)) = full_ref.split_once('.') {
3411        for import in &caller_data.import_block.imports {
3412            if import.namespace_import.as_deref() == Some(namespace) {
3413                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3414                    if let Some((file, symbol)) =
3415                        resolve_exported_symbol(index, &target_file, member, 0)
3416                    {
3417                        return Some(("resolved".to_string(), file, symbol));
3418                    }
3419                }
3420            }
3421        }
3422    }
3423
3424    for import in &caller_data.import_block.imports {
3425        for spec in &import.names {
3426            if crate::imports::specifier_local_name(spec) == short_name {
3427                if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3428                    let requested = crate::imports::specifier_imported_name(spec);
3429                    let (file, symbol) = resolve_exported_symbol(index, &target_file, requested, 0)
3430                        .unwrap_or_else(|| (target_file, requested.to_string()));
3431                    return Some(("resolved".to_string(), file, symbol));
3432                }
3433            }
3434        }
3435
3436        if import.default_import.as_deref() == Some(short_name) {
3437            if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3438                let (file, symbol) = resolve_exported_symbol(index, &target_file, "default", 0)
3439                    .or_else(|| {
3440                        index
3441                            .files
3442                            .get(&target_file)
3443                            .and_then(|file| file.default_export.clone())
3444                            .map(|symbol| (target_file.clone(), symbol))
3445                    })
3446                    .unwrap_or_else(|| {
3447                        let file_name = Path::new(&target_file)
3448                            .file_name()
3449                            .and_then(|name| name.to_str())
3450                            .unwrap_or("unknown")
3451                            .to_string();
3452                        (target_file, format!("<default:{file_name}>"))
3453                    });
3454                return Some(("resolved".to_string(), file, symbol));
3455            }
3456        }
3457    }
3458
3459    for import in &caller_data.import_block.imports {
3460        if let Some(target_file) = index.module_target(caller_file, &import.module_path) {
3461            if index
3462                .files
3463                .get(&target_file)
3464                .map(|file| file.exports.contains(short_name))
3465                .unwrap_or(false)
3466            {
3467                return Some(("resolved".to_string(), target_file, short_name.to_string()));
3468            }
3469        }
3470    }
3471
3472    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3473}
3474
3475fn resolve_exported_symbol(
3476    index: &ProjectIndex<'_>,
3477    file: &str,
3478    requested: &str,
3479    depth: usize,
3480) -> Option<(String, String)> {
3481    if depth > 16 {
3482        return None;
3483    }
3484    if requested != "default" {
3485        if let Some(source_symbol) = index
3486            .files
3487            .get(file)
3488            .and_then(|item| item.export_aliases.get(requested))
3489        {
3490            return Some((file.to_string(), source_symbol.clone()));
3491        }
3492        if index
3493            .files
3494            .get(file)
3495            .map(|item| item.exports.contains(requested))
3496            .unwrap_or(false)
3497        {
3498            return Some((file.to_string(), requested.to_string()));
3499        }
3500    } else if let Some(default) = index
3501        .files
3502        .get(file)
3503        .and_then(|item| item.default_export.clone())
3504    {
3505        return Some((file.to_string(), default));
3506    }
3507
3508    for reexport in index.reexports_for(file) {
3509        let mut next_requested = requested.to_string();
3510        let matches = if reexport.wildcard {
3511            true
3512        } else if let Some(source_name) = reexport.named.get(requested) {
3513            next_requested = source_name.clone();
3514            true
3515        } else {
3516            false
3517        };
3518        if !matches {
3519            continue;
3520        }
3521        if let Some(target_file) = &reexport.target_file {
3522            if let Some(target) =
3523                resolve_exported_symbol(index, target_file, &next_requested, depth + 1)
3524            {
3525                return Some(target);
3526            }
3527        }
3528    }
3529    None
3530}
3531
3532fn resolve_rust_target(
3533    index: &ProjectIndex<'_>,
3534    caller_file: &str,
3535    full_ref: &str,
3536    short_name: &str,
3537    caller_data: &FileCallData,
3538) -> Option<(String, String, String)> {
3539    if full_ref.contains("::") {
3540        if let Some(target_file) = rust_target_for_qualified(index, caller_file, full_ref) {
3541            return Some((
3542                "resolved".to_string(),
3543                target_file,
3544                rust_target_symbol(full_ref, short_name),
3545            ));
3546        }
3547    }
3548
3549    for import in &caller_data.import_block.imports {
3550        if let Some((target_file, target_symbol)) =
3551            rust_target_for_use(index, caller_file, import, short_name)
3552        {
3553            return Some(("resolved".to_string(), target_file, target_symbol));
3554        }
3555    }
3556
3557    resolve_local_target(index, caller_file, full_ref, short_name, caller_data)
3558}
3559
3560fn rust_target_for_qualified(
3561    index: &ProjectIndex<'_>,
3562    caller_file: &str,
3563    full_ref: &str,
3564) -> Option<String> {
3565    let mut segments: Vec<&str> = full_ref.split("::").collect();
3566    if segments.len() < 2 {
3567        return None;
3568    }
3569    segments.pop();
3570    if !matches!(segments.first().copied(), Some("crate" | "self" | "super")) {
3571        if let Some(target) = rust_workspace_file_for_segments(index, &segments) {
3572            return Some(target);
3573        }
3574    }
3575    let module_segments = rust_resolve_segments(caller_file, &segments)?;
3576    rust_file_for_segments(index, caller_file, &module_segments)
3577}
3578
3579fn rust_target_symbol(full_ref: &str, short_name: &str) -> String {
3580    full_ref
3581        .rsplit("::")
3582        .next()
3583        .filter(|name| !name.is_empty())
3584        .unwrap_or(short_name)
3585        .to_string()
3586}
3587
3588fn rust_target_for_use(
3589    index: &ProjectIndex<'_>,
3590    caller_file: &str,
3591    import: &ImportStatement,
3592    short_name: &str,
3593) -> Option<(String, String)> {
3594    let path = import.module_path.trim().trim_end_matches(';');
3595    if let Some(brace_start) = path.find("::{") {
3596        let prefix = &path[..brace_start];
3597        if import.names.iter().any(|name| name == short_name) {
3598            let prefix_segments: Vec<&str> = prefix.split("::").collect();
3599            let module_segments = rust_resolve_segments(caller_file, &prefix_segments)?;
3600            let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3601            return Some((file, short_name.to_string()));
3602        }
3603        return None;
3604    }
3605
3606    let (path_without_alias, alias) = path
3607        .split_once(" as ")
3608        .map(|(left, right)| (left.trim(), Some(right.trim())))
3609        .unwrap_or((path, None));
3610    let segments: Vec<&str> = path_without_alias.split("::").collect();
3611    let imported = alias.or_else(|| segments.last().copied())?;
3612    if imported != short_name {
3613        return None;
3614    }
3615    if segments.len() < 2 {
3616        return None;
3617    }
3618    let module_segments = rust_resolve_segments(caller_file, &segments[..segments.len() - 1])?;
3619    let file = rust_file_for_segments(index, caller_file, &module_segments)?;
3620    Some((file, segments.last().unwrap_or(&short_name).to_string()))
3621}
3622
3623fn rust_workspace_file_for_segments(index: &ProjectIndex<'_>, segments: &[&str]) -> Option<String> {
3624    let crate_name = segments.first().copied()?;
3625    let src_prefix = index.crate_src_prefix(crate_name)?;
3626    let module_segments = segments[1..]
3627        .iter()
3628        .map(|segment| segment.to_string())
3629        .collect::<Vec<_>>();
3630    rust_file_for_src_prefix(index, &src_prefix, &module_segments)
3631}
3632
3633/// Walk the project tree once and map every Rust crate name (package name with
3634/// `-` normalized to `_`, plus any explicit `[lib] name`) to its `src` prefix.
3635/// Replaces the previous per-ref tree walk: resolving 600k+ qualified refs no
3636/// longer re-walks the filesystem once per ref.
3637fn build_workspace_crate_prefixes(project_root: &Path) -> HashMap<String, String> {
3638    let mut prefixes = HashMap::new();
3639    let mut stack = vec![project_root.to_path_buf()];
3640    while let Some(dir) = stack.pop() {
3641        let name = dir.file_name().and_then(|name| name.to_str()).unwrap_or("");
3642        if matches!(name, "target" | "node_modules" | ".git") {
3643            continue;
3644        }
3645        let manifest = dir.join("Cargo.toml");
3646        if manifest.is_file() {
3647            let crate_names = rust_manifest_crate_names(&manifest);
3648            if !crate_names.is_empty() {
3649                let src_prefix = relative_path(project_root, &canonicalize_path(&dir.join("src")));
3650                for crate_name in crate_names {
3651                    prefixes
3652                        .entry(crate_name)
3653                        .or_insert_with(|| src_prefix.clone());
3654                }
3655            }
3656        }
3657        let Ok(entries) = std::fs::read_dir(&dir) else {
3658            continue;
3659        };
3660        for entry in entries.flatten() {
3661            let path = entry.path();
3662            if path.is_dir() {
3663                stack.push(path);
3664            }
3665        }
3666    }
3667    prefixes
3668}
3669
3670/// Extract the crate names a manifest defines: the normalized package name
3671/// (`-` -> `_`) and any explicit `[lib] name`. Returns both so a crate is
3672/// reachable by either spelling, matching the previous match semantics.
3673fn rust_manifest_crate_names(manifest: &Path) -> Vec<String> {
3674    let Ok(source) = std::fs::read_to_string(manifest) else {
3675        return Vec::new();
3676    };
3677    let mut in_lib = false;
3678    let mut package_name = None;
3679    let mut lib_name = None;
3680    for line in source.lines() {
3681        let trimmed = line.trim();
3682        if trimmed.starts_with('[') {
3683            in_lib = trimmed == "[lib]";
3684            continue;
3685        }
3686        let Some((key, value)) = trimmed.split_once('=') else {
3687            continue;
3688        };
3689        let key = key.trim();
3690        let value = value.trim().trim_matches('"');
3691        if in_lib && key == "name" {
3692            lib_name = Some(value.to_string());
3693        } else if !in_lib && key == "name" && package_name.is_none() {
3694            package_name = Some(value.to_string());
3695        }
3696    }
3697    let mut names = Vec::new();
3698    if let Some(lib) = lib_name {
3699        names.push(lib);
3700    }
3701    if let Some(package) = package_name {
3702        let normalized = package.replace('-', "_");
3703        if !names.contains(&normalized) {
3704            names.push(normalized);
3705        }
3706    }
3707    names
3708}
3709
3710fn rust_resolve_segments(caller_file: &str, segments: &[&str]) -> Option<Vec<String>> {
3711    if segments.is_empty() {
3712        return Some(Vec::new());
3713    }
3714    let caller_segments = rust_module_segments_for_rel(caller_file);
3715    match segments[0] {
3716        "crate" => Some(segments[1..].iter().map(|item| item.to_string()).collect()),
3717        "self" => {
3718            let mut resolved = caller_segments;
3719            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3720            Some(resolved)
3721        }
3722        "super" => {
3723            let mut resolved = caller_segments;
3724            resolved.pop();
3725            resolved.extend(segments[1..].iter().map(|item| item.to_string()));
3726            Some(resolved)
3727        }
3728        _ => {
3729            let mut resolved = caller_segments;
3730            resolved.pop();
3731            resolved.extend(segments.iter().map(|item| item.to_string()));
3732            Some(resolved)
3733        }
3734    }
3735}
3736
3737fn rust_file_for_segments(
3738    index: &ProjectIndex<'_>,
3739    caller_file: &str,
3740    segments: &[String],
3741) -> Option<String> {
3742    rust_file_for_src_prefix(index, &rust_src_prefix(caller_file), segments)
3743}
3744
3745fn rust_file_for_src_prefix(
3746    index: &ProjectIndex<'_>,
3747    src_prefix: &str,
3748    segments: &[String],
3749) -> Option<String> {
3750    let candidate = if segments.is_empty() {
3751        [src_prefix, "lib.rs"].join("/")
3752    } else {
3753        format!("{}/{}.rs", src_prefix, segments.join("/"))
3754    };
3755    if index.files.contains_key(&candidate) {
3756        return Some(candidate);
3757    }
3758    if !segments.is_empty() {
3759        let mod_candidate = format!("{}/{}/mod.rs", src_prefix, segments.join("/"));
3760        if index.files.contains_key(&mod_candidate) {
3761            return Some(mod_candidate);
3762        }
3763    }
3764    None
3765}
3766
3767fn rust_src_prefix(rel_path: &str) -> String {
3768    rel_path
3769        .split_once("/src/")
3770        .map(|(prefix, _)| format!("{prefix}/src"))
3771        .unwrap_or_else(|| "src".to_string())
3772}
3773
3774fn rust_module_segments_for_rel(rel_path: &str) -> Vec<String> {
3775    let after_src = rel_path
3776        .split_once("/src/")
3777        .map(|(_, rest)| rest)
3778        .or_else(|| rel_path.strip_prefix("src/"))
3779        .unwrap_or(rel_path);
3780    if matches!(after_src, "lib.rs" | "main.rs") {
3781        return Vec::new();
3782    }
3783    if let Some(prefix) = after_src.strip_suffix("/mod.rs") {
3784        return prefix.split('/').map(|item| item.to_string()).collect();
3785    }
3786    after_src
3787        .strip_suffix(".rs")
3788        .unwrap_or(after_src)
3789        .split('/')
3790        .map(|item| item.to_string())
3791        .collect()
3792}
3793
3794fn resolve_local_target(
3795    _index: &ProjectIndex<'_>,
3796    caller_file: &str,
3797    full_ref: &str,
3798    short_name: &str,
3799    caller_data: &FileCallData,
3800) -> Option<(String, String, String)> {
3801    if !callgraph::is_bare_callee(full_ref, short_name) {
3802        return None;
3803    }
3804    callgraph::resolve_symbol_query_in_data(caller_data, Path::new(caller_file), short_name)
3805        .ok()
3806        .map(|symbol| {
3807            (
3808                "resolved_local".to_string(),
3809                caller_file.to_string(),
3810                symbol,
3811            )
3812        })
3813}
3814
3815impl<'a> ProjectIndex<'a> {
3816    fn from_parts(
3817        project_root: &Path,
3818        files: HashMap<String, DbFileIndex>,
3819        caller_data: HashMap<String, &'a FileCallData>,
3820    ) -> Self {
3821        Self {
3822            project_root: project_root.to_path_buf(),
3823            files,
3824            caller_data,
3825            workspace_crate_prefixes: std::sync::OnceLock::new(),
3826        }
3827    }
3828
3829    fn from_extracts(project_root: &Path, extracts: &'a [FileExtract]) -> Self {
3830        let mut files = HashMap::new();
3831        let mut caller_data = HashMap::new();
3832        for extract in extracts {
3833            let index = DbFileIndex::from_extract(project_root, extract);
3834            caller_data.insert(extract.rel_path.clone(), &extract.data);
3835            files.insert(extract.rel_path.clone(), index);
3836        }
3837        Self::from_parts(project_root, files, caller_data)
3838    }
3839
3840    fn from_db_and_callers(
3841        tx: &Transaction<'_>,
3842        project_root: &Path,
3843        caller_extracts: &'a HashMap<String, FileExtract>,
3844    ) -> Result<Self> {
3845        let mut files = load_db_file_indexes(tx, project_root)?;
3846        let mut caller_data = HashMap::new();
3847        for (rel_path, extract) in caller_extracts {
3848            files.insert(
3849                rel_path.clone(),
3850                DbFileIndex::from_extract(project_root, extract),
3851            );
3852            caller_data.insert(rel_path.clone(), &extract.data);
3853        }
3854        Ok(Self::from_parts(project_root, files, caller_data))
3855    }
3856
3857    fn lang_for(&self, rel_path: &str) -> Option<LangId> {
3858        self.files.get(rel_path).and_then(|file| file.lang)
3859    }
3860
3861    fn module_target(&self, caller_file: &str, module_path: &str) -> Option<String> {
3862        self.files
3863            .get(caller_file)
3864            .and_then(|file| file.module_targets.get(module_path).cloned().flatten())
3865    }
3866
3867    fn reexports_for(&self, rel_path: &str) -> &[ReexportIndex] {
3868        self.files
3869            .get(rel_path)
3870            .map(|file| file.reexports.as_slice())
3871            .unwrap_or(&[])
3872    }
3873
3874    fn node_for_symbol(&self, rel_path: &str, symbol: &str) -> Option<String> {
3875        self.files.get(rel_path).and_then(|file| {
3876            file.node_by_scoped
3877                .get(symbol)
3878                .cloned()
3879                .or_else(|| file.node_by_bare.get(symbol).cloned())
3880        })
3881    }
3882}
3883
3884impl DbFileIndex {
3885    fn from_extract(project_root: &Path, extract: &FileExtract) -> Self {
3886        let mut node_by_scoped = HashMap::new();
3887        let mut node_by_bare = HashMap::new();
3888        for node in &extract.nodes {
3889            node_by_scoped.insert(node.scoped_name.clone(), node.id.clone());
3890            node_by_bare
3891                .entry(node.name.clone())
3892                .or_insert(node.id.clone());
3893        }
3894        let mut export_aliases = HashMap::new();
3895        for raw_ref in &extract.raw_refs {
3896            if raw_ref.kind == "export_alias" {
3897                if let (Some(exported), Some(source_symbol)) =
3898                    (&raw_ref.local_name, &raw_ref.requested_name)
3899                {
3900                    export_aliases.insert(exported.clone(), source_symbol.clone());
3901                }
3902            }
3903        }
3904        let mut module_targets = HashMap::new();
3905        for import in &extract.data.import_block.imports {
3906            module_targets.insert(
3907                import.module_path.clone(),
3908                module_target_from_dependencies(
3909                    project_root,
3910                    &module_dependencies(project_root, &extract.abs_path, &import.module_path),
3911                ),
3912            );
3913        }
3914        let mut reexports = Vec::new();
3915        for raw_ref in &extract.raw_refs {
3916            if raw_ref.kind == "reexport" {
3917                if let Some(module_path) = &raw_ref.module_path {
3918                    let target_file =
3919                        module_target_from_dependencies(project_root, &raw_ref.dependencies);
3920                    module_targets.insert(module_path.clone(), target_file.clone());
3921                    reexports.push(reexport_index_from_raw(raw_ref, target_file));
3922                }
3923            }
3924        }
3925        Self {
3926            lang: Some(extract.lang),
3927            exports: extract.data.exported_symbols.iter().cloned().collect(),
3928            default_export: extract.data.default_export_symbol.clone(),
3929            export_aliases,
3930            node_by_scoped,
3931            node_by_bare,
3932            module_targets,
3933            reexports,
3934        }
3935    }
3936}
3937
3938fn load_db_file_indexes(
3939    tx: &Transaction<'_>,
3940    project_root: &Path,
3941) -> Result<HashMap<String, DbFileIndex>> {
3942    let mut files = HashMap::new();
3943    let mut stmt = tx.prepare("SELECT path, lang FROM files")?;
3944    let rows = stmt.query_map([], |row| {
3945        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3946    })?;
3947    for row in rows {
3948        let (rel_path, lang) = row?;
3949        files.insert(
3950            rel_path.clone(),
3951            DbFileIndex {
3952                lang: lang_from_label(&lang),
3953                exports: HashSet::new(),
3954                default_export: None,
3955                export_aliases: HashMap::new(),
3956                node_by_scoped: HashMap::new(),
3957                node_by_bare: HashMap::new(),
3958                module_targets: HashMap::new(),
3959                reexports: Vec::new(),
3960            },
3961        );
3962    }
3963
3964    let mut node_stmt = tx.prepare(
3965        "SELECT file_path, id, name, scoped_name, exported, is_default_export FROM nodes",
3966    )?;
3967    let nodes = node_stmt.query_map([], |row| {
3968        Ok((
3969            row.get::<_, String>(0)?,
3970            row.get::<_, String>(1)?,
3971            row.get::<_, String>(2)?,
3972            row.get::<_, String>(3)?,
3973            row.get::<_, i64>(4)? != 0,
3974            row.get::<_, i64>(5)? != 0,
3975        ))
3976    })?;
3977    for row in nodes {
3978        let (file_path, id, name, scoped_name, exported, is_default_export) = row?;
3979        let file = files
3980            .entry(file_path.clone())
3981            .or_insert_with(|| DbFileIndex {
3982                lang: None,
3983                exports: HashSet::new(),
3984                default_export: None,
3985                export_aliases: HashMap::new(),
3986                node_by_scoped: HashMap::new(),
3987                node_by_bare: HashMap::new(),
3988                module_targets: HashMap::new(),
3989                reexports: Vec::new(),
3990            });
3991        if exported {
3992            file.exports.insert(name.clone());
3993            file.exports.insert(scoped_name.clone());
3994        }
3995        if is_default_export {
3996            file.default_export = Some(scoped_name.clone());
3997        }
3998        file.node_by_scoped.insert(scoped_name, id.clone());
3999        file.node_by_bare.entry(name).or_insert(id);
4000    }
4001    let file_keys: HashSet<String> = files.keys().cloned().collect();
4002    let mut ref_stmt = tx.prepare(
4003        "SELECT ref_id, caller_file, kind, module_path, full_ref, wildcard, local_name, requested_name
4004         FROM refs WHERE kind IN ('import', 'reexport', 'export_alias')",
4005    )?;
4006    let ref_rows = ref_stmt.query_map([], |row| {
4007        Ok((
4008            row.get::<_, String>(0)?,
4009            row.get::<_, String>(1)?,
4010            row.get::<_, String>(2)?,
4011            row.get::<_, Option<String>>(3)?,
4012            row.get::<_, Option<String>>(4)?,
4013            row.get::<_, i64>(5)? != 0,
4014            row.get::<_, Option<String>>(6)?,
4015            row.get::<_, Option<String>>(7)?,
4016        ))
4017    })?;
4018    for row in ref_rows {
4019        let (
4020            ref_id,
4021            caller_file,
4022            kind,
4023            module_path,
4024            full_ref,
4025            wildcard,
4026            local_name,
4027            requested_name,
4028        ) = row?;
4029        if kind == "export_alias" {
4030            if let (Some(exported), Some(source_symbol), Some(file)) =
4031                (local_name, requested_name, files.get_mut(&caller_file))
4032            {
4033                file.export_aliases.insert(exported, source_symbol);
4034            }
4035            continue;
4036        }
4037        let Some(module_path) = module_path else {
4038            continue;
4039        };
4040        let deps = dependencies_for_ref(tx, project_root, &ref_id)?;
4041        let target_file = deps
4042            .iter()
4043            .find(|dep| file_keys.contains(*dep))
4044            .map(|dep| relative_path(project_root, &canonicalize_path(&project_root.join(dep))));
4045        if let Some(file) = files.get_mut(&caller_file) {
4046            file.module_targets
4047                .entry(module_path.clone())
4048                .or_insert_with(|| target_file.clone());
4049            if kind == "reexport" {
4050                let raw = RawRef {
4051                    ref_id,
4052                    caller_node: None,
4053                    caller_symbol: None,
4054                    caller_file,
4055                    kind,
4056                    short_name: None,
4057                    full_ref,
4058                    module_path: Some(module_path),
4059                    import_kind: Some("reexport".to_string()),
4060                    local_name: None,
4061                    requested_name: None,
4062                    namespace_alias: None,
4063                    wildcard,
4064                    line: 0,
4065                    byte_start: 0,
4066                    byte_end: 0,
4067                    dependencies: deps,
4068                };
4069                file.reexports
4070                    .push(reexport_index_from_raw(&raw, target_file));
4071            }
4072        }
4073    }
4074
4075    Ok(files)
4076}
4077
4078struct ColdBuildInsertStatements<'stmt> {
4079    file: Statement<'stmt>,
4080    node: Statement<'stmt>,
4081    file_dependency: Statement<'stmt>,
4082    dispatch_hint: Statement<'stmt>,
4083    backend_state: Statement<'stmt>,
4084    reference: Statement<'stmt>,
4085    edge: Statement<'stmt>,
4086}
4087
4088impl<'stmt> ColdBuildInsertStatements<'stmt> {
4089    fn new(tx: &'stmt Transaction<'_>) -> Result<Self> {
4090        Ok(Self {
4091            file: tx.prepare(
4092                "INSERT OR REPLACE INTO files(
4093                    path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4094                    is_public_api, surface_fingerprint, indexed_at
4095                ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4096            )?,
4097            node: tx.prepare(
4098                "INSERT OR REPLACE INTO nodes(
4099                    id, file_path, name, scoped_name, kind, start_line, start_col,
4100                    end_line, end_col, range_ordinal, signature, exported,
4101                    is_default_export, is_type_like, is_callgraph_entry_point, provenance
4102                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4103            )?,
4104            file_dependency: tx.prepare(
4105                "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4106            )?,
4107            dispatch_hint: tx.prepare(
4108                "INSERT OR REPLACE INTO dispatch_hints(
4109                    id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4110                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4111            )?,
4112            backend_state: tx.prepare(
4113                "INSERT OR REPLACE INTO backend_file_state(
4114                    backend, workspace_root, file_path, content_hash, status, updated_at
4115                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
4116            )?,
4117            reference: tx.prepare(
4118                "INSERT OR REPLACE INTO refs(
4119                    ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4120                    import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4121                    byte_start, byte_end, status, target_node, target_file, target_symbol,
4122                    provenance
4123                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4124            )?,
4125            edge: tx.prepare(
4126                "INSERT OR REPLACE INTO edges(
4127                    edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4128                    kind, line, provenance
4129                ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4130            )?,
4131        })
4132    }
4133}
4134
4135fn insert_file_extract_prepared(
4136    statements: &mut ColdBuildInsertStatements<'_>,
4137    workspace_root: &str,
4138    extract: &FileExtract,
4139) -> Result<()> {
4140    statements.file.execute(params![
4141        extract.rel_path,
4142        hash_to_hex(extract.freshness.content_hash),
4143        system_time_to_ns(extract.freshness.mtime),
4144        extract.freshness.size as i64,
4145        lang_label(extract.lang),
4146        extract.surface_fingerprint,
4147        unix_seconds_now(),
4148    ])?;
4149    for node in &extract.nodes {
4150        statements.node.execute(params![
4151            node.id,
4152            node.file_path,
4153            node.name,
4154            node.scoped_name,
4155            node.kind,
4156            node.range.start_line as i64,
4157            node.range.start_col as i64,
4158            node.range.end_line as i64,
4159            node.range.end_col as i64,
4160            node.range_ordinal as i64,
4161            node.signature,
4162            bool_int(node.exported),
4163            bool_int(node.is_default_export),
4164            bool_int(node.is_type_like),
4165            bool_int(node.is_callgraph_entry_point),
4166            PROVENANCE_TREESITTER,
4167        ])?;
4168    }
4169
4170    let mut dependencies = BTreeSet::new();
4171    for raw_ref in &extract.raw_refs {
4172        dependencies.extend(raw_ref.dependencies.iter().cloned());
4173    }
4174    for dep_file in &dependencies {
4175        statements
4176            .file_dependency
4177            .execute(params![extract.rel_path, dep_file])?;
4178    }
4179
4180    for hint in &extract.dispatch_hints {
4181        statements.dispatch_hint.execute(params![
4182            hint.id,
4183            hint.method_name,
4184            hint.caller_node,
4185            hint.file,
4186            hint.line as i64,
4187            hint.byte_start as i64,
4188            hint.byte_end as i64,
4189            PROVENANCE_TREESITTER,
4190        ])?;
4191    }
4192    insert_backend_state_prepared(
4193        &mut statements.backend_state,
4194        workspace_root,
4195        &extract.rel_path,
4196        Some(&extract.freshness.content_hash),
4197        "fresh",
4198    )?;
4199    Ok(())
4200}
4201
4202fn insert_backend_state_prepared(
4203    stmt: &mut Statement<'_>,
4204    workspace_root: &str,
4205    rel_path: &str,
4206    content_hash: Option<&blake3::Hash>,
4207    status: &str,
4208) -> Result<()> {
4209    let hash = content_hash
4210        .map(|hash| hash_to_hex(*hash))
4211        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
4212    stmt.execute(params![
4213        BACKEND_TREESITTER,
4214        workspace_root,
4215        rel_path,
4216        hash,
4217        status,
4218        unix_seconds_now(),
4219    ])?;
4220    Ok(())
4221}
4222
4223fn insert_resolved_ref_prepared(
4224    statements: &mut ColdBuildInsertStatements<'_>,
4225    resolved: &ResolvedRef,
4226) -> Result<()> {
4227    let raw = &resolved.raw;
4228    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4229    statements.reference.execute(params![
4230        raw.ref_id,
4231        raw.caller_node,
4232        raw.caller_file,
4233        raw.kind,
4234        raw.short_name,
4235        raw.full_ref,
4236        raw.module_path,
4237        raw.import_kind,
4238        raw.local_name,
4239        raw.requested_name,
4240        raw.namespace_alias,
4241        bool_int(raw.wildcard),
4242        raw.line as i64,
4243        raw.byte_start as i64,
4244        raw.byte_end as i64,
4245        resolved.status,
4246        resolved.target_node,
4247        resolved.target_file,
4248        resolved.target_symbol,
4249        PROVENANCE_TREESITTER,
4250    ])?;
4251    if let Some(edge) = &resolved.edge {
4252        statements.edge.execute(params![
4253            edge.edge_id,
4254            raw.ref_id,
4255            edge.source_node,
4256            edge.target_node,
4257            edge.target_file,
4258            edge.target_symbol,
4259            edge.kind,
4260            edge.line as i64,
4261            PROVENANCE_TREESITTER,
4262        ])?;
4263    }
4264    Ok(())
4265}
4266
4267fn insert_file_extract(
4268    tx: &Transaction<'_>,
4269    project_root: &Path,
4270    extract: &FileExtract,
4271) -> Result<()> {
4272    tx.execute(
4273        "INSERT OR REPLACE INTO files(
4274            path, content_hash, mtime_ns, size, lang, is_dead_code_root,
4275            is_public_api, surface_fingerprint, indexed_at
4276        ) VALUES(?1, ?2, ?3, ?4, ?5, 0, 0, ?6, ?7)",
4277        params![
4278            extract.rel_path,
4279            hash_to_hex(extract.freshness.content_hash),
4280            system_time_to_ns(extract.freshness.mtime),
4281            extract.freshness.size as i64,
4282            lang_label(extract.lang),
4283            extract.surface_fingerprint,
4284            unix_seconds_now(),
4285        ],
4286    )?;
4287    for node in &extract.nodes {
4288        tx.execute(
4289            "INSERT OR REPLACE INTO nodes(
4290                id, file_path, name, scoped_name, kind, start_line, start_col,
4291                end_line, end_col, range_ordinal, signature, exported,
4292                is_default_export, is_type_like, is_callgraph_entry_point, provenance
4293            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
4294            params![
4295                node.id,
4296                node.file_path,
4297                node.name,
4298                node.scoped_name,
4299                node.kind,
4300                node.range.start_line as i64,
4301                node.range.start_col as i64,
4302                node.range.end_line as i64,
4303                node.range.end_col as i64,
4304                node.range_ordinal as i64,
4305                node.signature,
4306                bool_int(node.exported),
4307                bool_int(node.is_default_export),
4308                bool_int(node.is_type_like),
4309                bool_int(node.is_callgraph_entry_point),
4310                PROVENANCE_TREESITTER,
4311            ],
4312        )?;
4313    }
4314    let mut dependencies = BTreeSet::new();
4315    for raw_ref in &extract.raw_refs {
4316        dependencies.extend(raw_ref.dependencies.iter().cloned());
4317    }
4318    insert_file_dependencies(tx, &extract.rel_path, &dependencies)?;
4319
4320    for hint in &extract.dispatch_hints {
4321        tx.execute(
4322            "INSERT OR REPLACE INTO dispatch_hints(
4323                id, method_name, caller_node, file, line, byte_start, byte_end, provenance
4324            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
4325            params![
4326                hint.id,
4327                hint.method_name,
4328                hint.caller_node,
4329                hint.file,
4330                hint.line as i64,
4331                hint.byte_start as i64,
4332                hint.byte_end as i64,
4333                PROVENANCE_TREESITTER,
4334            ],
4335        )?;
4336    }
4337    mark_backend_state(
4338        tx,
4339        project_root,
4340        &extract.rel_path,
4341        Some(&extract.freshness.content_hash),
4342        "fresh",
4343    )?;
4344    Ok(())
4345}
4346
4347fn insert_file_dependencies(
4348    tx: &Transaction<'_>,
4349    file_path: &str,
4350    dependencies: &BTreeSet<String>,
4351) -> Result<()> {
4352    for dep_file in dependencies {
4353        tx.execute(
4354            "INSERT OR IGNORE INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
4355            params![file_path, dep_file],
4356        )?;
4357    }
4358    Ok(())
4359}
4360
4361fn insert_resolved_ref(tx: &Transaction<'_>, resolved: &ResolvedRef) -> Result<()> {
4362    let raw = &resolved.raw;
4363    debug_assert!(resolved.dependencies.is_superset(&raw.dependencies));
4364    tx.execute(
4365        "INSERT OR REPLACE INTO refs(
4366            ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
4367            import_kind, local_name, requested_name, namespace_alias, wildcard, line,
4368            byte_start, byte_end, status, target_node, target_file, target_symbol,
4369            provenance
4370        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
4371        params![
4372            raw.ref_id,
4373            raw.caller_node,
4374            raw.caller_file,
4375            raw.kind,
4376            raw.short_name,
4377            raw.full_ref,
4378            raw.module_path,
4379            raw.import_kind,
4380            raw.local_name,
4381            raw.requested_name,
4382            raw.namespace_alias,
4383            bool_int(raw.wildcard),
4384            raw.line as i64,
4385            raw.byte_start as i64,
4386            raw.byte_end as i64,
4387            resolved.status,
4388            resolved.target_node,
4389            resolved.target_file,
4390            resolved.target_symbol,
4391            PROVENANCE_TREESITTER,
4392        ],
4393    )?;
4394    if let Some(edge) = &resolved.edge {
4395        tx.execute(
4396            "INSERT OR REPLACE INTO edges(
4397                edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4398                kind, line, provenance
4399            ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4400            params![
4401                edge.edge_id,
4402                raw.ref_id,
4403                edge.source_node,
4404                edge.target_node,
4405                edge.target_file,
4406                edge.target_symbol,
4407                edge.kind,
4408                edge.line as i64,
4409                PROVENANCE_TREESITTER,
4410            ],
4411        )?;
4412    }
4413    Ok(())
4414}
4415
4416fn insert_method_dispatch_edges(
4417    tx: &Transaction<'_>,
4418    project_root: &Path,
4419    caller_files: Option<&BTreeSet<String>>,
4420) -> Result<usize> {
4421    let references = load_name_match_refs(tx, caller_files)?;
4422    if references.is_empty() {
4423        return Ok(0);
4424    }
4425
4426    let mut candidates_by_name: HashMap<(String, String), Vec<NameMatchCandidate>> = HashMap::new();
4427    let mut source_cache: DispatchSourceCache = HashMap::new();
4428    let mut inserted = 0usize;
4429    for reference in references {
4430        let key = (reference.method_name.clone(), reference.lang.clone());
4431        let candidates = match candidates_by_name.entry(key) {
4432            Entry::Occupied(entry) => entry.into_mut(),
4433            Entry::Vacant(entry) => {
4434                let candidates =
4435                    load_name_match_candidates(tx, &reference.method_name, &reference.lang)?;
4436                entry.insert(candidates)
4437            }
4438        };
4439
4440        if let Some(receiver_type) =
4441            infer_receiver_type(project_root, &reference, &mut source_cache)
4442        {
4443            let Some(candidate) =
4444                select_type_match_candidate(&reference, candidates.as_slice(), &receiver_type)
4445            else {
4446                continue;
4447            };
4448            insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_TYPE_MATCH)?;
4449            inserted += 1;
4450            continue;
4451        }
4452
4453        if method_name_match_denylisted(&reference.method_name) {
4454            continue;
4455        }
4456
4457        let Some(candidate) = select_name_match_candidate(&reference, candidates.as_slice()) else {
4458            continue;
4459        };
4460        insert_method_dispatch_edge(tx, &reference, &candidate, PROVENANCE_NAME_MATCH)?;
4461        inserted += 1;
4462    }
4463    Ok(inserted)
4464}
4465
4466fn insert_method_dispatch_edges_chunked(
4467    tx: &Transaction<'_>,
4468    project_root: &Path,
4469    caller_files: &BTreeSet<String>,
4470    chunk_size: usize,
4471) -> Result<usize> {
4472    if caller_files.is_empty() {
4473        return Ok(0);
4474    }
4475    if chunk_size == 0 || caller_files.len() <= chunk_size {
4476        return insert_method_dispatch_edges(tx, project_root, Some(caller_files));
4477    }
4478
4479    let mut inserted = 0usize;
4480    let mut batch = BTreeSet::new();
4481    for caller_file in caller_files {
4482        batch.insert(caller_file.clone());
4483        if batch.len() == chunk_size {
4484            inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4485            batch.clear();
4486        }
4487    }
4488    if !batch.is_empty() {
4489        inserted += insert_method_dispatch_edges(tx, project_root, Some(&batch))?;
4490    }
4491    Ok(inserted)
4492}
4493
4494fn insert_method_dispatch_edge(
4495    tx: &Transaction<'_>,
4496    reference: &NameMatchRef,
4497    candidate: &NameMatchCandidate,
4498    provenance: &str,
4499) -> Result<()> {
4500    tx.execute(
4501        "INSERT OR REPLACE INTO edges(
4502            edge_id, ref_id, source_node, target_node, target_file, target_symbol,
4503            kind, line, provenance
4504        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
4505        params![
4506            ref_id(&[&reference.ref_id, provenance, "edge"]),
4507            &reference.ref_id,
4508            &reference.caller_node,
4509            &candidate.node_id,
4510            &candidate.file_path,
4511            &candidate.scoped_name,
4512            reference.line as i64,
4513            provenance,
4514        ],
4515    )?;
4516    Ok(())
4517}
4518
4519fn delete_method_dispatch_edges_for_callers(
4520    tx: &Transaction<'_>,
4521    caller_files: &BTreeSet<String>,
4522) -> Result<()> {
4523    if caller_files.is_empty() {
4524        return Ok(());
4525    }
4526
4527    let mut stmt = tx.prepare(
4528        "DELETE FROM edges
4529         WHERE provenance IN (?1, ?2)
4530           AND ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?3)",
4531    )?;
4532    for caller_file in caller_files {
4533        stmt.execute(params![
4534            PROVENANCE_NAME_MATCH,
4535            PROVENANCE_TYPE_MATCH,
4536            caller_file
4537        ])?;
4538    }
4539    Ok(())
4540}
4541
4542fn load_name_match_refs(
4543    tx: &Transaction<'_>,
4544    caller_files: Option<&BTreeSet<String>>,
4545) -> Result<Vec<NameMatchRef>> {
4546    let base_sql = "SELECT r.ref_id, r.caller_node, r.caller_file, n.scoped_name,
4547                           n.signature, r.short_name, r.full_ref, r.line, f.lang
4548                    FROM refs r
4549                    JOIN files f ON f.path = r.caller_file
4550                    JOIN nodes n ON n.id = r.caller_node
4551                    WHERE r.kind = 'call'
4552                      AND r.status = 'unresolved'
4553                      AND r.caller_node IS NOT NULL
4554                      AND r.full_ref IS NOT NULL
4555                      AND (r.full_ref LIKE '%.%' OR r.full_ref LIKE '%::%' OR r.full_ref LIKE '%->%')
4556                      AND NOT EXISTS (
4557                          SELECT 1 FROM edges e WHERE e.ref_id = r.ref_id AND e.kind = 'call'
4558                      )";
4559    let mut references = Vec::new();
4560
4561    if let Some(caller_files) = caller_files {
4562        if caller_files.is_empty() {
4563            return Ok(references);
4564        }
4565        let sql = format!(
4566            "{base_sql} AND r.caller_file = ?1 ORDER BY r.caller_file, r.byte_start, r.ref_id"
4567        );
4568        let mut stmt = tx.prepare(&sql)?;
4569        for caller_file in caller_files {
4570            let rows = stmt.query_map(params![caller_file], |row| {
4571                Ok((
4572                    row.get::<_, String>(0)?,
4573                    row.get::<_, Option<String>>(1)?,
4574                    row.get::<_, String>(2)?,
4575                    row.get::<_, String>(3)?,
4576                    row.get::<_, Option<String>>(4)?,
4577                    row.get::<_, Option<String>>(5)?,
4578                    row.get::<_, Option<String>>(6)?,
4579                    row.get::<_, i64>(7)?,
4580                    row.get::<_, String>(8)?,
4581                ))
4582            })?;
4583            for row in rows {
4584                let (
4585                    ref_id,
4586                    caller_node,
4587                    caller_file,
4588                    caller_symbol,
4589                    caller_signature,
4590                    short_name,
4591                    full_ref,
4592                    line,
4593                    lang,
4594                ) = row?;
4595                if let Some(reference) = name_match_ref_from_parts(
4596                    ref_id,
4597                    caller_node,
4598                    caller_file,
4599                    caller_symbol,
4600                    caller_signature,
4601                    short_name,
4602                    full_ref,
4603                    line,
4604                    lang,
4605                ) {
4606                    references.push(reference);
4607                }
4608            }
4609        }
4610        return Ok(references);
4611    }
4612
4613    let sql = format!("{base_sql} ORDER BY r.caller_file, r.byte_start, r.ref_id");
4614    let mut stmt = tx.prepare(&sql)?;
4615    let rows = stmt.query_map([], |row| {
4616        Ok((
4617            row.get::<_, String>(0)?,
4618            row.get::<_, Option<String>>(1)?,
4619            row.get::<_, String>(2)?,
4620            row.get::<_, String>(3)?,
4621            row.get::<_, Option<String>>(4)?,
4622            row.get::<_, Option<String>>(5)?,
4623            row.get::<_, Option<String>>(6)?,
4624            row.get::<_, i64>(7)?,
4625            row.get::<_, String>(8)?,
4626        ))
4627    })?;
4628    for row in rows {
4629        let (
4630            ref_id,
4631            caller_node,
4632            caller_file,
4633            caller_symbol,
4634            caller_signature,
4635            short_name,
4636            full_ref,
4637            line,
4638            lang,
4639        ) = row?;
4640        if let Some(reference) = name_match_ref_from_parts(
4641            ref_id,
4642            caller_node,
4643            caller_file,
4644            caller_symbol,
4645            caller_signature,
4646            short_name,
4647            full_ref,
4648            line,
4649            lang,
4650        ) {
4651            references.push(reference);
4652        }
4653    }
4654    Ok(references)
4655}
4656
4657#[allow(clippy::too_many_arguments)]
4658fn name_match_ref_from_parts(
4659    ref_id: String,
4660    caller_node: Option<String>,
4661    caller_file: String,
4662    caller_symbol: String,
4663    caller_signature: Option<String>,
4664    short_name: Option<String>,
4665    full_ref: Option<String>,
4666    line: i64,
4667    lang: String,
4668) -> Option<NameMatchRef> {
4669    let caller_node = caller_node?;
4670    let full_ref = full_ref?;
4671    let (receiver, member, colon_dispatch) = parse_method_dispatch(&full_ref)?;
4672    let method_name = if member.is_empty() {
4673        short_name.as_deref()?.to_string()
4674    } else {
4675        member
4676    };
4677    Some(NameMatchRef {
4678        ref_id,
4679        caller_node,
4680        caller_file,
4681        caller_symbol,
4682        caller_signature,
4683        receiver,
4684        method_name,
4685        colon_dispatch,
4686        line: line.max(0) as u32,
4687        lang,
4688    })
4689}
4690
4691fn parse_method_dispatch(full_ref: &str) -> Option<(String, String, bool)> {
4692    let dot = full_ref.rfind('.').map(|index| (index, 1usize, false));
4693    let colon = full_ref.rfind("::").map(|index| (index, 2usize, true));
4694    let arrow = full_ref.rfind("->").map(|index| (index, 2usize, false));
4695    let (delimiter, delimiter_len, colon_dispatch) = [dot, colon, arrow]
4696        .into_iter()
4697        .flatten()
4698        .max_by_key(|(index, _, _)| *index)?;
4699    if delimiter == 0 {
4700        return None;
4701    }
4702    let member_start = delimiter + delimiter_len;
4703    if member_start >= full_ref.len() {
4704        return None;
4705    }
4706    let receiver = last_name_segment(&full_ref[..delimiter]);
4707    let member = &full_ref[member_start..];
4708    if receiver.is_empty() || member.is_empty() {
4709        return None;
4710    }
4711    Some((receiver.to_string(), member.to_string(), colon_dispatch))
4712}
4713
4714fn last_name_segment(value: &str) -> &str {
4715    value
4716        .rsplit(['.', ':', '/', '\\', '-', '>'])
4717        .find(|segment| !segment.is_empty())
4718        .unwrap_or(value)
4719}
4720
4721fn load_name_match_candidates(
4722    tx: &Transaction<'_>,
4723    method_name: &str,
4724    lang: &str,
4725) -> Result<Vec<NameMatchCandidate>> {
4726    let mut stmt = tx.prepare(
4727        "SELECT n.id, n.file_path, n.scoped_name, n.kind
4728         FROM nodes n JOIN files f ON f.path = n.file_path
4729         WHERE n.name = ?1
4730           AND f.lang = ?2
4731           AND n.kind IN ('method', 'function')
4732         ORDER BY n.file_path, n.scoped_name, n.start_line, n.start_col, n.id",
4733    )?;
4734    let rows = stmt.query_map(params![method_name, lang], |row| {
4735        Ok(NameMatchCandidate {
4736            node_id: row.get(0)?,
4737            file_path: row.get(1)?,
4738            scoped_name: row.get(2)?,
4739            kind: row.get(3)?,
4740        })
4741    })?;
4742    rows.collect::<std::result::Result<Vec<_>, _>>()
4743        .map_err(Into::into)
4744}
4745
4746struct ParsedDispatchSource {
4747    source: String,
4748    tree: tree_sitter::Tree,
4749}
4750
4751type DispatchSourceCache = HashMap<(String, String), Option<ParsedDispatchSource>>;
4752
4753fn infer_receiver_type(
4754    project_root: &Path,
4755    reference: &NameMatchRef,
4756    source_cache: &mut DispatchSourceCache,
4757) -> Option<String> {
4758    match reference.lang.as_str() {
4759        "rust" => infer_rust_receiver_type(reference),
4760        "java" => {
4761            infer_java_like_receiver_type(project_root, reference, LangId::Java, source_cache)
4762        }
4763        "kotlin" => {
4764            infer_java_like_receiver_type(project_root, reference, LangId::Kotlin, source_cache)
4765        }
4766        "cpp" => infer_cpp_receiver_type(project_root, reference, source_cache),
4767        _ => None,
4768    }
4769}
4770
4771fn parse_dispatch_source(
4772    project_root: &Path,
4773    caller_file: &str,
4774    lang: LangId,
4775) -> Option<ParsedDispatchSource> {
4776    let source = std::fs::read_to_string(project_root.join(caller_file)).ok()?;
4777    let grammar = crate::parser::grammar_for(lang);
4778    let mut parser = tree_sitter::Parser::new();
4779    parser.set_language(&grammar).ok()?;
4780    let tree = parser.parse(&source, None)?;
4781    Some(ParsedDispatchSource { source, tree })
4782}
4783
4784fn parsed_dispatch_source<'a>(
4785    project_root: &Path,
4786    reference: &NameMatchRef,
4787    lang: LangId,
4788    source_cache: &'a mut DispatchSourceCache,
4789) -> Option<&'a ParsedDispatchSource> {
4790    let key = (reference.caller_file.clone(), reference.lang.clone());
4791    source_cache
4792        .entry(key)
4793        .or_insert_with(|| parse_dispatch_source(project_root, &reference.caller_file, lang))
4794        .as_ref()
4795}
4796
4797fn infer_java_like_receiver_type(
4798    project_root: &Path,
4799    reference: &NameMatchRef,
4800    lang: LangId,
4801    source_cache: &mut DispatchSourceCache,
4802) -> Option<String> {
4803    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4804        return None;
4805    }
4806
4807    let parsed = parsed_dispatch_source(project_root, reference, lang, source_cache)?;
4808    let root = parsed.tree.root_node();
4809    let type_node = find_enclosing_java_like_type_node(root, &parsed.source, reference, lang);
4810
4811    let callable_scope = type_node
4812        .and_then(|node| {
4813            find_enclosing_java_like_callable_node(node, &parsed.source, reference, lang)
4814        })
4815        .or_else(|| find_enclosing_java_like_callable_node(root, &parsed.source, reference, lang));
4816
4817    if let Some(callable_scope) = callable_scope {
4818        if let Some(receiver_type) = infer_java_like_local_receiver_type(
4819            callable_scope,
4820            &parsed.source,
4821            &reference.receiver,
4822            reference.line.max(1),
4823            lang,
4824        ) {
4825            return Some(receiver_type);
4826        }
4827    }
4828
4829    type_node.and_then(|node| {
4830        infer_java_like_field_receiver_type(node, &parsed.source, &reference.receiver, lang)
4831    })
4832}
4833
4834fn infer_cpp_receiver_type(
4835    project_root: &Path,
4836    reference: &NameMatchRef,
4837    source_cache: &mut DispatchSourceCache,
4838) -> Option<String> {
4839    if reference.colon_dispatch || !receiver_is_bare_identifier(&reference.receiver) {
4840        return None;
4841    }
4842
4843    let parsed = parsed_dispatch_source(project_root, reference, LangId::Cpp, source_cache)?;
4844    let root = parsed.tree.root_node();
4845    let scope = find_enclosing_cpp_callable_node(root, &parsed.source, reference).unwrap_or(root);
4846    infer_cpp_receiver_type_from_scope(
4847        scope,
4848        &parsed.source,
4849        &reference.receiver,
4850        reference.line.max(1),
4851    )
4852}
4853
4854fn find_enclosing_java_like_type_node<'tree>(
4855    root: tree_sitter::Node<'tree>,
4856    source: &str,
4857    reference: &NameMatchRef,
4858    lang: LangId,
4859) -> Option<tree_sitter::Node<'tree>> {
4860    let expected_type = enclosing_type_from_scoped_name(&reference.caller_symbol)
4861        .and_then(|name| simple_type_name(&name));
4862    let line = reference.line.max(1);
4863    let mut best = None;
4864    let mut stack = vec![root];
4865    while let Some(node) = stack.pop() {
4866        if !node_contains_line(node, line) {
4867            continue;
4868        }
4869        if is_java_like_type_kind(node.kind(), lang) {
4870            let name = declaration_name(node, source);
4871            if expected_type
4872                .as_deref()
4873                .is_none_or(|expected| name == Some(expected))
4874            {
4875                best = tighter_node(best, node);
4876            }
4877        }
4878        push_named_children(node, &mut stack);
4879    }
4880    best
4881}
4882
4883fn find_enclosing_java_like_callable_node<'tree>(
4884    root: tree_sitter::Node<'tree>,
4885    source: &str,
4886    reference: &NameMatchRef,
4887    lang: LangId,
4888) -> Option<tree_sitter::Node<'tree>> {
4889    let expected_name = reference.caller_symbol.rsplit("::").next();
4890    let line = reference.line.max(1);
4891    let mut best = None;
4892    let mut stack = vec![root];
4893    while let Some(node) = stack.pop() {
4894        if !node_contains_line(node, line) {
4895            continue;
4896        }
4897        if is_java_like_callable_kind(node.kind(), lang) {
4898            let name = declaration_name(node, source);
4899            if expected_name.is_none_or(|expected| name == Some(expected)) {
4900                best = tighter_node(best, node);
4901            }
4902        }
4903        push_named_children(node, &mut stack);
4904    }
4905    best
4906}
4907
4908fn find_enclosing_cpp_callable_node<'tree>(
4909    root: tree_sitter::Node<'tree>,
4910    _source: &str,
4911    reference: &NameMatchRef,
4912) -> Option<tree_sitter::Node<'tree>> {
4913    let line = reference.line.max(1);
4914    let mut best = None;
4915    let mut stack = vec![root];
4916    while let Some(node) = stack.pop() {
4917        if !node_contains_line(node, line) {
4918            continue;
4919        }
4920        if node.kind() == "function_definition" {
4921            best = tighter_node(best, node);
4922        }
4923        push_named_children(node, &mut stack);
4924    }
4925    best
4926}
4927
4928fn tighter_node<'tree>(
4929    current: Option<tree_sitter::Node<'tree>>,
4930    candidate: tree_sitter::Node<'tree>,
4931) -> Option<tree_sitter::Node<'tree>> {
4932    match current {
4933        Some(current)
4934            if current.start_byte() > candidate.start_byte()
4935                || (current.start_byte() == candidate.start_byte()
4936                    && current.end_byte() <= candidate.end_byte()) =>
4937        {
4938            Some(current)
4939        }
4940        _ => Some(candidate),
4941    }
4942}
4943
4944fn node_contains_line(node: tree_sitter::Node<'_>, line: u32) -> bool {
4945    let start = node.start_position().row as u32 + 1;
4946    let end = node.end_position().row as u32 + 1;
4947    start <= line && line <= end
4948}
4949
4950fn push_named_children<'tree>(
4951    node: tree_sitter::Node<'tree>,
4952    stack: &mut Vec<tree_sitter::Node<'tree>>,
4953) {
4954    for index in 0..node.named_child_count() {
4955        if let Some(child) = node.named_child(index as u32) {
4956            stack.push(child);
4957        }
4958    }
4959}
4960
4961fn declaration_name<'source>(
4962    node: tree_sitter::Node<'_>,
4963    source: &'source str,
4964) -> Option<&'source str> {
4965    node.child_by_field_name("name")
4966        .map(|name| node_text(name, source))
4967        .or_else(|| {
4968            first_named_child_text(
4969                node,
4970                source,
4971                &["identifier", "type_identifier", "simple_identifier"],
4972            )
4973        })
4974}
4975
4976fn first_named_child_text<'source>(
4977    node: tree_sitter::Node<'_>,
4978    source: &'source str,
4979    kinds: &[&str],
4980) -> Option<&'source str> {
4981    for index in 0..node.named_child_count() {
4982        let child = node.named_child(index as u32)?;
4983        if kinds.contains(&child.kind()) {
4984            return Some(node_text(child, source));
4985        }
4986    }
4987    None
4988}
4989
4990fn node_text<'source>(node: tree_sitter::Node<'_>, source: &'source str) -> &'source str {
4991    &source[node.byte_range()]
4992}
4993
4994fn infer_java_like_field_receiver_type(
4995    type_node: tree_sitter::Node<'_>,
4996    source: &str,
4997    receiver: &str,
4998    lang: LangId,
4999) -> Option<String> {
5000    let mut stack = Vec::new();
5001    push_named_children(type_node, &mut stack);
5002    while let Some(node) = stack.pop() {
5003        if is_java_like_field_kind(node.kind(), lang) {
5004            if let Some(receiver_type) =
5005                extract_java_like_declared_type(node_text(node, source), receiver, lang)
5006            {
5007                return Some(receiver_type);
5008            }
5009        }
5010        if is_java_like_type_kind(node.kind(), lang)
5011            || is_java_like_callable_kind(node.kind(), lang)
5012        {
5013            continue;
5014        }
5015        push_named_children(node, &mut stack);
5016    }
5017    None
5018}
5019
5020fn infer_java_like_local_receiver_type(
5021    callable_node: tree_sitter::Node<'_>,
5022    source: &str,
5023    receiver: &str,
5024    call_line: u32,
5025    lang: LangId,
5026) -> Option<String> {
5027    let mut best: Option<(u32, String)> = None;
5028    let mut stack = Vec::new();
5029    push_named_children(callable_node, &mut stack);
5030    while let Some(node) = stack.pop() {
5031        let start_line = node.start_position().row as u32 + 1;
5032        if start_line > call_line {
5033            continue;
5034        }
5035        if is_java_like_local_kind(node.kind(), lang) {
5036            if let Some(receiver_type) =
5037                extract_java_like_declared_type(node_text(node, source), receiver, lang)
5038            {
5039                if best
5040                    .as_ref()
5041                    .is_none_or(|(best_line, _)| start_line >= *best_line)
5042                {
5043                    best = Some((start_line, receiver_type));
5044                }
5045            }
5046        }
5047        if is_java_like_type_kind(node.kind(), lang)
5048            || is_java_like_callable_kind(node.kind(), lang)
5049        {
5050            continue;
5051        }
5052        push_named_children(node, &mut stack);
5053    }
5054    best.map(|(_, receiver_type)| receiver_type)
5055}
5056
5057fn is_java_like_type_kind(kind: &str, lang: LangId) -> bool {
5058    match lang {
5059        LangId::Java => matches!(
5060            kind,
5061            "class_declaration"
5062                | "interface_declaration"
5063                | "enum_declaration"
5064                | "record_declaration"
5065                | "annotation_type_declaration"
5066        ),
5067        LangId::Kotlin => matches!(kind, "class_declaration" | "object_declaration"),
5068        _ => false,
5069    }
5070}
5071
5072fn is_java_like_callable_kind(kind: &str, lang: LangId) -> bool {
5073    match lang {
5074        LangId::Java => matches!(kind, "method_declaration" | "constructor_declaration"),
5075        LangId::Kotlin => kind == "function_declaration",
5076        _ => false,
5077    }
5078}
5079
5080fn is_java_like_field_kind(kind: &str, lang: LangId) -> bool {
5081    match lang {
5082        LangId::Java => kind == "field_declaration",
5083        LangId::Kotlin => kind == "property_declaration",
5084        _ => false,
5085    }
5086}
5087
5088fn is_java_like_local_kind(kind: &str, lang: LangId) -> bool {
5089    match lang {
5090        LangId::Java => kind == "local_variable_declaration",
5091        LangId::Kotlin => kind == "property_declaration",
5092        _ => false,
5093    }
5094}
5095
5096fn extract_java_like_declared_type(
5097    declaration: &str,
5098    receiver: &str,
5099    lang: LangId,
5100) -> Option<String> {
5101    match lang {
5102        LangId::Java => extract_java_declared_type(declaration, receiver),
5103        LangId::Kotlin => extract_kotlin_declared_type(declaration, receiver),
5104        _ => None,
5105    }
5106}
5107
5108fn extract_java_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5109    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5110    let after = declaration[receiver_start + receiver.len()..].trim_start();
5111    if after
5112        .chars()
5113        .next()
5114        .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '['))
5115    {
5116        return None;
5117    }
5118
5119    let before = declaration[..receiver_start].trim_end();
5120    if before.contains(',') {
5121        return None;
5122    }
5123    normalize_receiver_type_name(strip_java_declaration_prefixes(before))
5124}
5125
5126fn strip_java_declaration_prefixes(mut value: &str) -> &str {
5127    loop {
5128        value = value.trim_start();
5129        if let Some(stripped) = strip_leading_java_annotation(value) {
5130            value = stripped;
5131            continue;
5132        }
5133        if let Some(stripped) = strip_leading_java_modifier(value) {
5134            value = stripped;
5135            continue;
5136        }
5137        return value.trim();
5138    }
5139}
5140
5141fn strip_leading_java_annotation(value: &str) -> Option<&str> {
5142    let value = value.trim_start();
5143    let mut chars = value.char_indices();
5144    let (_, first) = chars.next()?;
5145    if first != '@' {
5146        return None;
5147    }
5148    let mut end = first.len_utf8();
5149    for (index, ch) in chars {
5150        if !(is_code_ident_char(ch) || ch == '.') {
5151            end = index;
5152            break;
5153        }
5154        end = index + ch.len_utf8();
5155    }
5156    let rest = value[end..].trim_start();
5157    if let Some(stripped) = rest.strip_prefix('(') {
5158        let mut depth = 1usize;
5159        for (index, ch) in stripped.char_indices() {
5160            match ch {
5161                '(' => depth += 1,
5162                ')' => {
5163                    depth = depth.saturating_sub(1);
5164                    if depth == 0 {
5165                        return Some(stripped[index + ch.len_utf8()..].trim_start());
5166                    }
5167                }
5168                _ => {}
5169            }
5170        }
5171        return Some("");
5172    }
5173    Some(rest)
5174}
5175
5176fn strip_leading_java_modifier(value: &str) -> Option<&str> {
5177    const MODIFIERS: &[&str] = &[
5178        "public",
5179        "protected",
5180        "private",
5181        "abstract",
5182        "static",
5183        "final",
5184        "transient",
5185        "volatile",
5186        "synchronized",
5187        "native",
5188        "strictfp",
5189    ];
5190    MODIFIERS
5191        .iter()
5192        .find_map(|modifier| strip_leading_word(value, modifier))
5193}
5194
5195fn extract_kotlin_declared_type(declaration: &str, receiver: &str) -> Option<String> {
5196    let receiver_start = find_identifier_occurrence(declaration, receiver)?;
5197    let before = &declaration[..receiver_start];
5198    if find_identifier_occurrence(before, "val").is_none()
5199        && find_identifier_occurrence(before, "var").is_none()
5200    {
5201        return None;
5202    }
5203
5204    let after = declaration[receiver_start + receiver.len()..].trim_start();
5205    if let Some(type_text) = after.strip_prefix(':') {
5206        return normalize_receiver_type_name(read_type_prefix(type_text));
5207    }
5208    after
5209        .strip_prefix('=')
5210        .and_then(infer_kotlin_constructor_type)
5211}
5212
5213fn infer_kotlin_constructor_type(rhs: &str) -> Option<String> {
5214    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Kotlin)?;
5215    if rest.trim_start().starts_with('(') {
5216        normalize_receiver_type_name(head)
5217    } else {
5218        None
5219    }
5220}
5221
5222fn read_type_prefix(value: &str) -> &str {
5223    let mut angle_depth = 0usize;
5224    for (index, ch) in value.char_indices() {
5225        match ch {
5226            '<' => angle_depth += 1,
5227            '>' => angle_depth = angle_depth.saturating_sub(1),
5228            '=' | ';' | '\n' | '\r' | '{' | ',' | ')' if angle_depth == 0 => {
5229                return value[..index].trim();
5230            }
5231            _ => {}
5232        }
5233    }
5234    value.trim()
5235}
5236
5237fn infer_cpp_receiver_type_from_scope(
5238    scope: tree_sitter::Node<'_>,
5239    source: &str,
5240    receiver: &str,
5241    call_line: u32,
5242) -> Option<String> {
5243    let lines = source.lines().collect::<Vec<_>>();
5244    if lines.is_empty() {
5245        return None;
5246    }
5247    let scope_start = scope.start_position().row as usize;
5248    let call_index = (call_line as usize)
5249        .saturating_sub(1)
5250        .min(lines.len().saturating_sub(1));
5251    for index in (scope_start..=call_index).rev() {
5252        if let Some(receiver_type) = infer_cpp_receiver_type_from_line(lines[index], receiver) {
5253            return Some(receiver_type);
5254        }
5255    }
5256    None
5257}
5258
5259fn infer_cpp_receiver_type_from_line(line: &str, receiver: &str) -> Option<String> {
5260    for receiver_start in identifier_occurrences(line, receiver) {
5261        let after = line[receiver_start + receiver.len()..].trim_start();
5262        if after
5263            .chars()
5264            .next()
5265            .is_some_and(|ch| !matches!(ch, ';' | '=' | ',' | ')' | '[' | '{' | '('))
5266        {
5267            continue;
5268        }
5269        let type_text = cpp_type_before_receiver(&line[..receiver_start])?;
5270        let normalized = normalize_cpp_type_name(type_text)?;
5271        if normalized == "auto" {
5272            if let Some(rhs) = after.strip_prefix('=') {
5273                return infer_cpp_auto_receiver_type(rhs);
5274            }
5275            continue;
5276        }
5277        return Some(normalized);
5278    }
5279    None
5280}
5281
5282fn cpp_type_before_receiver(prefix: &str) -> Option<&str> {
5283    let candidate = prefix
5284        .rsplit([';', '{', '}', '('])
5285        .next()
5286        .unwrap_or(prefix)
5287        .trim();
5288    if candidate.is_empty() || candidate.ends_with(',') {
5289        None
5290    } else {
5291        Some(candidate)
5292    }
5293}
5294
5295fn normalize_cpp_type_name(type_text: &str) -> Option<String> {
5296    let without_templates = strip_angle_groups(type_text);
5297    let mut cleaned = String::with_capacity(without_templates.len());
5298    for token in without_templates.split_whitespace() {
5299        if matches!(
5300            token,
5301            "const" | "volatile" | "mutable" | "typename" | "class" | "struct"
5302        ) {
5303            continue;
5304        }
5305        if !cleaned.is_empty() {
5306            cleaned.push(' ');
5307        }
5308        cleaned.push_str(token);
5309    }
5310    let token = cleaned
5311        .split_whitespace()
5312        .last()
5313        .unwrap_or(cleaned.trim())
5314        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == ':' || ch == '.'))
5315        .trim_matches(['*', '&']);
5316    let simple = token.rsplit("::").next().unwrap_or(token).trim();
5317    if simple.is_empty() || cpp_non_type_token(simple) {
5318        None
5319    } else {
5320        Some(simple.to_string())
5321    }
5322}
5323
5324fn infer_cpp_auto_receiver_type(rhs: &str) -> Option<String> {
5325    let rhs = rhs.trim_start();
5326    if let Some(after_new) = rhs.strip_prefix("new ") {
5327        return infer_cpp_constructor_type(after_new);
5328    }
5329    infer_cpp_make_template_type(rhs)
5330        .or_else(|| infer_cpp_constructor_type(rhs))
5331        .or_else(|| infer_cpp_factory_type(rhs))
5332}
5333
5334fn infer_cpp_constructor_type(rhs: &str) -> Option<String> {
5335    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5336    let normalized = normalize_cpp_type_name(head)?;
5337    if !normalized
5338        .chars()
5339        .next()
5340        .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5341    {
5342        return None;
5343    }
5344    if matches!(rest.trim_start().chars().next(), Some('(' | '{')) {
5345        Some(normalized)
5346    } else {
5347        None
5348    }
5349}
5350
5351fn infer_cpp_make_template_type(rhs: &str) -> Option<String> {
5352    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5353    if !rest.trim_start().starts_with('(') {
5354        return None;
5355    }
5356    let base = head.split('<').next().unwrap_or(head);
5357    let base_simple = base.rsplit("::").next().unwrap_or(base);
5358    if !matches!(base_simple, "make_unique" | "make_shared") {
5359        return None;
5360    }
5361    first_angle_arg(head).and_then(normalize_cpp_type_name)
5362}
5363
5364fn infer_cpp_factory_type(rhs: &str) -> Option<String> {
5365    let (head, rest) = read_invocation_head(rhs.trim_start(), JavaLikeInvocation::Cpp)?;
5366    if !rest.trim_start().starts_with('(') {
5367        return None;
5368    }
5369    let simple = head
5370        .split('<')
5371        .next()
5372        .unwrap_or(head)
5373        .rsplit("::")
5374        .next()
5375        .unwrap_or(head);
5376    for prefix in ["make", "create", "build"] {
5377        if let Some(suffix) = simple.strip_prefix(prefix) {
5378            if suffix
5379                .chars()
5380                .next()
5381                .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5382            {
5383                return normalize_cpp_type_name(suffix);
5384            }
5385        }
5386    }
5387    None
5388}
5389
5390#[derive(Debug, Clone, Copy)]
5391enum JavaLikeInvocation {
5392    Kotlin,
5393    Cpp,
5394}
5395
5396fn read_invocation_head(value: &str, flavor: JavaLikeInvocation) -> Option<(&str, &str)> {
5397    let value = value.trim_start();
5398    let mut end = 0usize;
5399    for (index, ch) in value.char_indices() {
5400        let allowed_separator = match flavor {
5401            JavaLikeInvocation::Kotlin => ch == '.',
5402            JavaLikeInvocation::Cpp => ch == ':' || ch == '.',
5403        };
5404        if is_code_ident_char(ch) || allowed_separator {
5405            end = index + ch.len_utf8();
5406            continue;
5407        }
5408        break;
5409    }
5410    if end == 0 {
5411        return None;
5412    }
5413    let mut rest = &value[end..];
5414    if let Some(stripped) = rest.trim_start().strip_prefix('<') {
5415        let skipped = skip_balanced_angle(stripped)?;
5416        let rest_start = rest.len() - rest.trim_start().len();
5417        let angle_len = 1 + skipped;
5418        end += rest_start + angle_len;
5419        rest = &value[end..];
5420    }
5421    Some((value[..end].trim(), rest))
5422}
5423
5424fn skip_balanced_angle(value_after_open: &str) -> Option<usize> {
5425    let mut depth = 1usize;
5426    for (index, ch) in value_after_open.char_indices() {
5427        match ch {
5428            '<' => depth += 1,
5429            '>' => {
5430                depth = depth.saturating_sub(1);
5431                if depth == 0 {
5432                    return Some(index + ch.len_utf8());
5433                }
5434            }
5435            _ => {}
5436        }
5437    }
5438    None
5439}
5440
5441fn first_angle_arg(value: &str) -> Option<&str> {
5442    let open = value.find('<')?;
5443    let inner_len = skip_balanced_angle(&value[open + 1..])?;
5444    let inner = &value[open + 1..open + inner_len];
5445    split_top_level_commas(inner).into_iter().next()
5446}
5447
5448fn normalize_receiver_type_name(type_text: &str) -> Option<String> {
5449    let without_generics = strip_angle_groups(type_text);
5450    let cleaned = without_generics
5451        .replace("[]", " ")
5452        .replace("...", " ")
5453        .replace(['?', '&', '*'], " ");
5454    let token = cleaned
5455        .split_whitespace()
5456        .last()
5457        .unwrap_or(cleaned.trim())
5458        .trim_matches(|ch: char| !(is_code_ident_char(ch) || ch == '.' || ch == ':'));
5459    let token = token.rsplit("::").next().unwrap_or(token);
5460    let simple = token.rsplit('.').next().unwrap_or(token).trim();
5461    if simple.is_empty()
5462        || java_like_primitive_type(simple)
5463        || !simple
5464            .chars()
5465            .next()
5466            .is_some_and(|ch| ch == '_' || ch.is_ascii_uppercase())
5467    {
5468        None
5469    } else {
5470        Some(simple.to_string())
5471    }
5472}
5473
5474fn simple_type_name(scoped_name: &str) -> Option<String> {
5475    scoped_name
5476        .rsplit("::")
5477        .find(|segment| !segment.is_empty())
5478        .and_then(normalize_receiver_type_name)
5479}
5480
5481fn strip_angle_groups(value: &str) -> String {
5482    let mut output = String::with_capacity(value.len());
5483    let mut depth = 0usize;
5484    for ch in value.chars() {
5485        match ch {
5486            '<' => {
5487                if depth == 0 {
5488                    output.push(' ');
5489                }
5490                depth += 1;
5491            }
5492            '>' => depth = depth.saturating_sub(1),
5493            _ if depth == 0 => output.push(ch),
5494            _ => {}
5495        }
5496    }
5497    output
5498}
5499
5500fn java_like_primitive_type(value: &str) -> bool {
5501    matches!(
5502        value,
5503        "boolean"
5504            | "byte"
5505            | "char"
5506            | "double"
5507            | "float"
5508            | "int"
5509            | "long"
5510            | "short"
5511            | "void"
5512            | "Boolean"
5513            | "Byte"
5514            | "Char"
5515            | "Double"
5516            | "Float"
5517            | "Int"
5518            | "Long"
5519            | "Short"
5520            | "Unit"
5521    )
5522}
5523
5524fn cpp_non_type_token(value: &str) -> bool {
5525    matches!(
5526        value,
5527        "return"
5528            | "if"
5529            | "else"
5530            | "for"
5531            | "while"
5532            | "do"
5533            | "switch"
5534            | "case"
5535            | "default"
5536            | "break"
5537            | "continue"
5538            | "goto"
5539            | "throw"
5540            | "new"
5541            | "delete"
5542            | "co_await"
5543            | "co_yield"
5544            | "co_return"
5545            | "static_cast"
5546            | "const_cast"
5547            | "dynamic_cast"
5548            | "reinterpret_cast"
5549            | "sizeof"
5550            | "alignof"
5551            | "typeid"
5552            | "and"
5553            | "or"
5554            | "not"
5555            | "xor"
5556    )
5557}
5558
5559fn receiver_is_bare_identifier(value: &str) -> bool {
5560    let mut chars = value.chars();
5561    let Some(first) = chars.next() else {
5562        return false;
5563    };
5564    (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_code_ident_char)
5565}
5566
5567fn find_identifier_occurrence(value: &str, needle: &str) -> Option<usize> {
5568    identifier_occurrences(value, needle).into_iter().next()
5569}
5570
5571fn identifier_occurrences(value: &str, needle: &str) -> Vec<usize> {
5572    value
5573        .match_indices(needle)
5574        .filter_map(|(index, _)| identifier_boundary(value, index, needle.len()).then_some(index))
5575        .collect()
5576}
5577
5578fn identifier_boundary(value: &str, start: usize, len: usize) -> bool {
5579    let before = value[..start].chars().next_back();
5580    let after = value[start + len..].chars().next();
5581    !before.is_some_and(is_code_ident_char) && !after.is_some_and(is_code_ident_char)
5582}
5583
5584fn strip_leading_word<'a>(value: &'a str, word: &str) -> Option<&'a str> {
5585    let stripped = value.strip_prefix(word)?;
5586    if stripped.is_empty() || stripped.chars().next().is_some_and(char::is_whitespace) {
5587        Some(stripped.trim_start())
5588    } else {
5589        None
5590    }
5591}
5592
5593fn is_code_ident_char(ch: char) -> bool {
5594    ch == '_' || ch.is_ascii_alphanumeric()
5595}
5596
5597fn infer_rust_receiver_type(reference: &NameMatchRef) -> Option<String> {
5598    if matches!(reference.receiver.as_str(), "self" | "Self") {
5599        return enclosing_type_from_scoped_name(&reference.caller_symbol);
5600    }
5601
5602    if reference.colon_dispatch && rust_receiver_looks_type_like(&reference.receiver) {
5603        return Some(reference.receiver.clone());
5604    }
5605
5606    reference
5607        .caller_signature
5608        .as_deref()
5609        .and_then(|signature| rust_parameter_type(signature, &reference.receiver))
5610}
5611
5612fn rust_receiver_looks_type_like(receiver: &str) -> bool {
5613    receiver
5614        .chars()
5615        .next()
5616        .is_some_and(|ch| ch == '_' || ch.is_uppercase())
5617}
5618
5619fn enclosing_type_from_scoped_name(scoped_name: &str) -> Option<String> {
5620    scoped_name
5621        .rsplit_once("::")
5622        .map(|(enclosing, _)| enclosing)
5623        .filter(|enclosing| !enclosing.is_empty() && *enclosing != TOP_LEVEL_SYMBOL)
5624        .map(ToString::to_string)
5625}
5626
5627fn rust_parameter_type(signature: &str, receiver: &str) -> Option<String> {
5628    let params = signature_parameter_text(signature)?;
5629    for param in split_top_level_commas(params) {
5630        let Some((pattern, type_text)) = param.split_once(':') else {
5631            continue;
5632        };
5633        let Some(name) = rust_parameter_name(pattern) else {
5634            continue;
5635        };
5636        if name == receiver {
5637            return normalize_rust_receiver_type(type_text);
5638        }
5639    }
5640    None
5641}
5642
5643fn signature_parameter_text(signature: &str) -> Option<&str> {
5644    let open = signature.find('(')?;
5645    let mut depth = 0usize;
5646    for (offset, ch) in signature[open..].char_indices() {
5647        match ch {
5648            '(' => depth += 1,
5649            ')' => {
5650                depth = depth.saturating_sub(1);
5651                if depth == 0 {
5652                    return Some(&signature[open + 1..open + offset]);
5653                }
5654            }
5655            _ => {}
5656        }
5657    }
5658    None
5659}
5660
5661fn split_top_level_commas(value: &str) -> Vec<&str> {
5662    let mut parts = Vec::new();
5663    let mut start = 0usize;
5664    let mut angle_depth = 0usize;
5665    let mut paren_depth = 0usize;
5666    let mut bracket_depth = 0usize;
5667    for (index, ch) in value.char_indices() {
5668        match ch {
5669            '<' => angle_depth += 1,
5670            '>' => angle_depth = angle_depth.saturating_sub(1),
5671            '(' => paren_depth += 1,
5672            ')' => paren_depth = paren_depth.saturating_sub(1),
5673            '[' => bracket_depth += 1,
5674            ']' => bracket_depth = bracket_depth.saturating_sub(1),
5675            ',' if angle_depth == 0 && paren_depth == 0 && bracket_depth == 0 => {
5676                let part = value[start..index].trim();
5677                if !part.is_empty() {
5678                    parts.push(part);
5679                }
5680                start = index + ch.len_utf8();
5681            }
5682            _ => {}
5683        }
5684    }
5685    let part = value[start..].trim();
5686    if !part.is_empty() {
5687        parts.push(part);
5688    }
5689    parts
5690}
5691
5692fn rust_parameter_name(pattern: &str) -> Option<&str> {
5693    let mut pattern = pattern.trim();
5694    if let Some(stripped) = pattern.strip_prefix("mut ") {
5695        pattern = stripped.trim_start();
5696    }
5697    pattern
5698        .rsplit(|ch: char| !is_rust_ident_char(ch))
5699        .find(|part| !part.is_empty())
5700}
5701
5702fn normalize_rust_receiver_type(type_text: &str) -> Option<String> {
5703    let mut ty = strip_leading_rust_type_modifiers(type_text);
5704    let owned_inner;
5705    if let Some(inner) = single_outer_generic_arg(ty) {
5706        owned_inner = inner.trim().to_string();
5707        ty = strip_leading_rust_type_modifiers(&owned_inner);
5708    }
5709    rust_base_type_ident(ty)
5710}
5711
5712fn strip_leading_rust_type_modifiers(mut ty: &str) -> &str {
5713    loop {
5714        ty = ty.trim_start();
5715        if let Some(stripped) = ty.strip_prefix('&') {
5716            ty = stripped.trim_start();
5717            if let Some(stripped) = strip_leading_lifetime(ty) {
5718                ty = stripped.trim_start();
5719            }
5720            if let Some(stripped) = ty.strip_prefix("mut ") {
5721                ty = stripped.trim_start();
5722            }
5723            continue;
5724        }
5725        if let Some(stripped) = ty.strip_prefix("mut ") {
5726            ty = stripped.trim_start();
5727            continue;
5728        }
5729        if let Some(stripped) = ty.strip_prefix("dyn ") {
5730            ty = stripped.trim_start();
5731            continue;
5732        }
5733        if let Some(stripped) = ty.strip_prefix("impl ") {
5734            ty = stripped.trim_start();
5735            continue;
5736        }
5737        break ty.trim();
5738    }
5739}
5740
5741fn strip_leading_lifetime(value: &str) -> Option<&str> {
5742    let mut chars = value.char_indices();
5743    let (_, first) = chars.next()?;
5744    if first != '\'' {
5745        return None;
5746    }
5747    for (index, ch) in chars {
5748        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
5749            return Some(&value[index..]);
5750        }
5751    }
5752    Some("")
5753}
5754
5755fn single_outer_generic_arg(ty: &str) -> Option<&str> {
5756    let ty = ty.trim();
5757    let open = ty.find('<')?;
5758    let mut depth = 0usize;
5759    let mut close = None;
5760    for (index, ch) in ty.char_indices().skip_while(|(index, _)| *index < open) {
5761        match ch {
5762            '<' => depth += 1,
5763            '>' => {
5764                depth = depth.saturating_sub(1);
5765                if depth == 0 {
5766                    close = Some(index);
5767                    break;
5768                }
5769            }
5770            _ => {}
5771        }
5772    }
5773    let close = close?;
5774    if !ty[close + 1..].trim().is_empty() {
5775        return None;
5776    }
5777    let inner = &ty[open + 1..close];
5778    let args = split_top_level_commas(inner);
5779    match args.as_slice() {
5780        [arg] => Some(*arg),
5781        _ => None,
5782    }
5783}
5784
5785fn rust_base_type_ident(ty: &str) -> Option<String> {
5786    let ty = ty.trim();
5787    let head = ty
5788        .split([' ', '+', '='])
5789        .find(|part| !part.is_empty())
5790        .unwrap_or(ty);
5791    let head = head.split('<').next().unwrap_or(head).trim();
5792    let ident = head
5793        .rsplit("::")
5794        .next()
5795        .unwrap_or(head)
5796        .trim_matches(|ch: char| !is_rust_ident_char(ch));
5797    if ident.is_empty() || ident.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5798        None
5799    } else {
5800        Some(ident.to_string())
5801    }
5802}
5803
5804fn is_rust_ident_char(ch: char) -> bool {
5805    ch == '_' || ch.is_ascii_alphanumeric()
5806}
5807
5808fn select_type_match_candidate(
5809    reference: &NameMatchRef,
5810    candidates: &[NameMatchCandidate],
5811    receiver_type: &str,
5812) -> Option<NameMatchCandidate> {
5813    let candidates = candidates
5814        .iter()
5815        .filter(|candidate| candidate.node_id != reference.caller_node)
5816        .filter(|candidate| {
5817            type_candidate_matches(candidate, receiver_type, &reference.method_name)
5818        })
5819        .collect::<Vec<_>>();
5820    match candidates.as_slice() {
5821        [candidate] => Some((**candidate).clone()),
5822        _ => None,
5823    }
5824}
5825
5826fn type_candidate_matches(
5827    candidate: &NameMatchCandidate,
5828    receiver_type: &str,
5829    method_name: &str,
5830) -> bool {
5831    let normalized_type = receiver_type.replace('.', "::");
5832    let suffix = format!("{normalized_type}::{method_name}");
5833    candidate.scoped_name == suffix || candidate.scoped_name.ends_with(&format!("::{suffix}"))
5834}
5835
5836fn select_name_match_candidate(
5837    reference: &NameMatchRef,
5838    candidates: &[NameMatchCandidate],
5839) -> Option<NameMatchCandidate> {
5840    let candidates = candidates
5841        .iter()
5842        .filter(|candidate| candidate.node_id != reference.caller_node)
5843        .filter(|candidate| candidate_allowed_for_reference(reference, candidate))
5844        .collect::<Vec<_>>();
5845    match candidates.as_slice() {
5846        [] => None,
5847        [candidate] => Some((**candidate).clone()),
5848        _ => select_scored_name_match_candidate(reference, &candidates),
5849    }
5850}
5851
5852fn candidate_allowed_for_reference(
5853    reference: &NameMatchRef,
5854    candidate: &NameMatchCandidate,
5855) -> bool {
5856    if !reference.colon_dispatch {
5857        return true;
5858    }
5859
5860    candidate.kind == "method"
5861        && candidate
5862            .scoped_name
5863            .split("::")
5864            .any(|segment| segment == reference.receiver)
5865}
5866
5867fn select_scored_name_match_candidate(
5868    reference: &NameMatchRef,
5869    candidates: &[&NameMatchCandidate],
5870) -> Option<NameMatchCandidate> {
5871    let receiver_words = split_camel_case(&reference.receiver);
5872    if receiver_words.is_empty() {
5873        return None;
5874    }
5875
5876    let mut best: Option<(&NameMatchCandidate, f64)> = None;
5877    let mut tied_best = false;
5878    for candidate in candidates {
5879        let candidate_words = split_camel_case(&candidate.scoped_name);
5880        let overlap = receiver_words
5881            .iter()
5882            .filter(|receiver_word| {
5883                candidate_words
5884                    .iter()
5885                    .any(|candidate_word| candidate_word == *receiver_word)
5886            })
5887            .count() as f64;
5888        let score =
5889            overlap + 1.0 + compute_path_proximity(&reference.caller_file, &candidate.file_path);
5890        match best {
5891            None => {
5892                best = Some((*candidate, score));
5893                tied_best = false;
5894            }
5895            Some((_, best_score)) if score > best_score => {
5896                best = Some((*candidate, score));
5897                tied_best = false;
5898            }
5899            Some((_, best_score)) if (score - best_score).abs() < f64::EPSILON => {
5900                tied_best = true;
5901            }
5902            _ => {}
5903        }
5904    }
5905
5906    let (candidate, score) = best?;
5907    if score >= NAME_MATCH_SCORE_THRESHOLD && !tied_best {
5908        Some(candidate.clone())
5909    } else {
5910        None
5911    }
5912}
5913
5914fn method_name_match_denylisted(method_name: &str) -> bool {
5915    matches!(
5916        method_name,
5917        "and_then"
5918            | "as_bytes"
5919            | "as_deref"
5920            | "as_mut"
5921            | "as_ref"
5922            | "as_str"
5923            | "borrow"
5924            | "borrow_mut"
5925            | "clear"
5926            | "clone"
5927            | "collect"
5928            | "contains"
5929            | "contains_key"
5930            | "count"
5931            | "dedup"
5932            | "default"
5933            | "drain"
5934            | "ends_with"
5935            | "entry"
5936            | "err"
5937            | "expect"
5938            | "extend"
5939            | "filter"
5940            | "filter_map"
5941            | "find"
5942            | "from"
5943            | "get"
5944            | "get_mut"
5945            | "insert"
5946            | "into"
5947            | "into_iter"
5948            | "is_empty"
5949            | "is_err"
5950            | "is_none"
5951            | "is_ok"
5952            | "is_some"
5953            | "iter"
5954            | "iter_mut"
5955            | "join"
5956            | "len"
5957            | "lock"
5958            | "map"
5959            | "map_err"
5960            | "max"
5961            | "min"
5962            | "new"
5963            | "next"
5964            | "ok"
5965            | "or_default"
5966            | "or_else"
5967            | "or_insert"
5968            | "or_insert_with"
5969            | "parse"
5970            | "pop"
5971            | "position"
5972            | "push"
5973            | "read"
5974            | "recv"
5975            | "remove"
5976            | "replace"
5977            | "retain"
5978            | "send"
5979            | "sort"
5980            | "sort_by"
5981            | "split"
5982            | "starts_with"
5983            | "sum"
5984            | "take"
5985            | "to_owned"
5986            | "to_string"
5987            | "trim"
5988            | "try_from"
5989            | "try_into"
5990            | "unwrap"
5991            | "unwrap_or"
5992            | "unwrap_or_default"
5993            | "unwrap_or_else"
5994            | "with_capacity"
5995            | "write"
5996    )
5997}
5998
5999fn split_camel_case(value: &str) -> Vec<String> {
6000    let chars = value.chars().collect::<Vec<_>>();
6001    let mut normalized = String::with_capacity(value.len() + 8);
6002    for (index, ch) in chars.iter().enumerate() {
6003        let previous = index.checked_sub(1).and_then(|prev| chars.get(prev));
6004        let next = chars.get(index + 1);
6005        let is_separator = ch.is_whitespace()
6006            || matches!(
6007                ch,
6008                '_' | '.' | ':' | '/' | '\\' | '-' | '<' | '>' | '(' | ')' | '[' | ']'
6009            );
6010        if is_separator {
6011            normalized.push(' ');
6012            continue;
6013        }
6014        let camel_boundary = previous.is_some_and(|prev| {
6015            (prev.is_lowercase() && ch.is_uppercase())
6016                || (prev.is_ascii_digit() && ch.is_alphabetic())
6017                || (prev.is_uppercase()
6018                    && ch.is_uppercase()
6019                    && next.is_some_and(|next| next.is_lowercase()))
6020        });
6021        if camel_boundary {
6022            normalized.push(' ');
6023        }
6024        normalized.push(*ch);
6025    }
6026
6027    normalized
6028        .split_whitespace()
6029        .filter(|word| word.len() > 1)
6030        .map(|word| word.to_ascii_lowercase())
6031        .collect()
6032}
6033
6034fn compute_path_proximity(left: &str, right: &str) -> f64 {
6035    let left_dirs = left
6036        .rsplit_once('/')
6037        .map(|(dir, _)| dir)
6038        .unwrap_or_default()
6039        .split('/')
6040        .filter(|part| !part.is_empty());
6041    let right_dirs = right
6042        .rsplit_once('/')
6043        .map(|(dir, _)| dir)
6044        .unwrap_or_default()
6045        .split('/')
6046        .filter(|part| !part.is_empty());
6047
6048    let shared = left_dirs
6049        .zip(right_dirs)
6050        .take_while(|(left, right)| left == right)
6051        .count();
6052    ((shared as f64) * 0.05).min(0.5)
6053}
6054
6055fn mark_backend_state(
6056    tx: &Transaction<'_>,
6057    project_root: &Path,
6058    rel_path: &str,
6059    content_hash: Option<&blake3::Hash>,
6060    status: &str,
6061) -> Result<()> {
6062    clear_backend_state_for_file(tx, project_root, rel_path)?;
6063    let hash = content_hash
6064        .map(|hash| hash_to_hex(*hash))
6065        .unwrap_or_else(|| hash_to_hex(cache_freshness::zero_hash()));
6066    tx.execute(
6067        "INSERT OR REPLACE INTO backend_file_state(
6068            backend, workspace_root, file_path, content_hash, status, updated_at
6069        ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
6070        params![
6071            BACKEND_TREESITTER,
6072            project_root.display().to_string(),
6073            rel_path,
6074            hash,
6075            status,
6076            unix_seconds_now(),
6077        ],
6078    )?;
6079    Ok(())
6080}
6081
6082fn clear_backend_state_for_file(
6083    tx: &Transaction<'_>,
6084    project_root: &Path,
6085    rel_path: &str,
6086) -> Result<()> {
6087    tx.execute(
6088        "DELETE FROM backend_file_state
6089         WHERE backend = ?1 AND workspace_root = ?2 AND file_path = ?3",
6090        params![
6091            BACKEND_TREESITTER,
6092            project_root.display().to_string(),
6093            rel_path
6094        ],
6095    )?;
6096    Ok(())
6097}
6098
6099fn load_file_row(tx: &Transaction<'_>, rel_path: &str) -> Result<Option<FileRow>> {
6100    tx.query_row(
6101        "SELECT surface_fingerprint, content_hash, mtime_ns, size FROM files WHERE path = ?1",
6102        params![rel_path],
6103        |row| {
6104            let hash_text: String = row.get(1)?;
6105            Ok(FileRow {
6106                surface_fingerprint: row.get(0)?,
6107                freshness: FileFreshness {
6108                    content_hash: hash_from_hex(&hash_text)
6109                        .unwrap_or_else(cache_freshness::zero_hash),
6110                    mtime: ns_to_system_time(row.get::<_, i64>(2)?),
6111                    size: row.get::<_, i64>(3)? as u64,
6112                },
6113            })
6114        },
6115    )
6116    .optional()
6117    .map_err(CallGraphStoreError::from)
6118}
6119
6120fn stored_node_ids_match_extract(
6121    tx: &Transaction<'_>,
6122    rel_path: &str,
6123    extract: &FileExtract,
6124) -> Result<bool> {
6125    let mut stmt = tx.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
6126    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6127    let mut stored = BTreeSet::new();
6128    for row in rows {
6129        stored.insert(row?);
6130    }
6131    let extracted = extract
6132        .nodes
6133        .iter()
6134        .map(|node| node.id.clone())
6135        .collect::<BTreeSet<_>>();
6136    Ok(stored == extracted)
6137}
6138
6139fn update_file_fresh_metadata(
6140    tx: &Transaction<'_>,
6141    rel_path: &str,
6142    hash: &blake3::Hash,
6143    mtime: SystemTime,
6144    size: u64,
6145) -> Result<()> {
6146    tx.execute(
6147        "UPDATE files SET mtime_ns = ?2, size = ?3, indexed_at = ?4 WHERE path = ?1",
6148        params![
6149            rel_path,
6150            system_time_to_ns(mtime),
6151            size as i64,
6152            unix_seconds_now()
6153        ],
6154    )?;
6155    tx.execute(
6156        "UPDATE backend_file_state SET status = 'fresh', updated_at = ?4
6157         WHERE backend = ?1 AND file_path = ?2 AND content_hash = ?3",
6158        params![
6159            BACKEND_TREESITTER,
6160            rel_path,
6161            hash_to_hex(*hash),
6162            unix_seconds_now(),
6163        ],
6164    )?;
6165    Ok(())
6166}
6167
6168#[derive(Debug, Clone, PartialEq, Eq)]
6169struct DependentRefSelection {
6170    ref_id: String,
6171    caller_file: String,
6172}
6173
6174fn ref_ids_depending_on(
6175    tx: &Transaction<'_>,
6176    project_root: &Path,
6177    rel_path: &str,
6178) -> Result<Vec<DependentRefSelection>> {
6179    let mut stmt = tx.prepare(
6180        "SELECT DISTINCT r.ref_id, r.kind, r.caller_file, r.module_path, r.target_file
6181         FROM refs r
6182         WHERE r.caller_file IN (
6183             SELECT file_path FROM file_dependencies WHERE dep_file = ?1
6184         )
6185            OR r.target_file = ?1
6186         ORDER BY r.ref_id",
6187    )?;
6188    let rows = stmt.query_map(params![rel_path], |row| {
6189        Ok(RefDependencyRow {
6190            ref_id: row.get(0)?,
6191            kind: row.get(1)?,
6192            caller_file: row.get(2)?,
6193            module_path: row.get(3)?,
6194            target_file: row.get(4)?,
6195        })
6196    })?;
6197    let mut ids = Vec::new();
6198    for row in rows {
6199        let row = row?;
6200        if ref_dependency_row_depends_on(project_root, &row, rel_path) {
6201            ids.push(DependentRefSelection {
6202                ref_id: row.ref_id,
6203                caller_file: row.caller_file,
6204            });
6205        }
6206    }
6207    Ok(ids)
6208}
6209
6210fn record_dependent_refs(
6211    selected_ref_ids: &mut BTreeSet<String>,
6212    selected_refs_by_caller: &mut BTreeMap<String, BTreeSet<String>>,
6213    dependent_refs: Vec<DependentRefSelection>,
6214) {
6215    for dependent_ref in dependent_refs {
6216        let DependentRefSelection {
6217            ref_id,
6218            caller_file,
6219        } = dependent_ref;
6220        selected_ref_ids.insert(ref_id.clone());
6221        selected_refs_by_caller
6222            .entry(caller_file)
6223            .or_default()
6224            .insert(ref_id);
6225    }
6226}
6227
6228#[cfg(test)]
6229fn refs_by_caller_for_ref_ids(
6230    tx: &Transaction<'_>,
6231    ref_ids: &BTreeSet<String>,
6232) -> Result<BTreeMap<String, BTreeSet<String>>> {
6233    let mut by_caller: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6234    let mut stmt = tx.prepare("SELECT caller_file FROM refs WHERE ref_id = ?1")?;
6235    for ref_id in ref_ids {
6236        if let Some(caller) = stmt
6237            .query_row(params![ref_id], |row| row.get::<_, String>(0))
6238            .optional()?
6239        {
6240            by_caller.entry(caller).or_default().insert(ref_id.clone());
6241        }
6242    }
6243    Ok(by_caller)
6244}
6245
6246fn delete_file_rows(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6247    tx.execute(
6248        "DELETE FROM file_dependencies WHERE file_path = ?1",
6249        params![rel_path],
6250    )?;
6251    delete_refs_for_caller(tx, rel_path)?;
6252    tx.execute(
6253        "DELETE FROM dispatch_hints WHERE file = ?1",
6254        params![rel_path],
6255    )?;
6256    tx.execute("DELETE FROM nodes WHERE file_path = ?1", params![rel_path])?;
6257    tx.execute("DELETE FROM files WHERE path = ?1", params![rel_path])?;
6258    Ok(())
6259}
6260
6261fn delete_refs_for_caller(tx: &Transaction<'_>, rel_path: &str) -> Result<()> {
6262    let mut stmt = tx.prepare("SELECT ref_id FROM refs WHERE caller_file = ?1")?;
6263    let rows = stmt.query_map(params![rel_path], |row| row.get::<_, String>(0))?;
6264    let mut ids = BTreeSet::new();
6265    for row in rows {
6266        ids.insert(row?);
6267    }
6268    delete_ref_ids(tx, &ids)
6269}
6270
6271fn delete_ref_ids(tx: &Transaction<'_>, ref_ids: &BTreeSet<String>) -> Result<()> {
6272    for ref_id in ref_ids {
6273        tx.execute("DELETE FROM edges WHERE ref_id = ?1", params![ref_id])?;
6274        tx.execute("DELETE FROM refs WHERE ref_id = ?1", params![ref_id])?;
6275    }
6276    Ok(())
6277}
6278
6279fn edge_snapshot_with_conn(conn: &Connection) -> Result<BTreeSet<StoredEdge>> {
6280    let mut stmt = conn.prepare(
6281        "SELECT source.file_path, source.scoped_name, edges.target_file,
6282                edges.target_symbol, edges.kind, edges.line
6283         FROM edges
6284         JOIN nodes AS source ON source.id = edges.source_node
6285         ORDER BY source.file_path, source.scoped_name, edges.target_file,
6286                  edges.target_symbol, edges.kind, edges.line",
6287    )?;
6288    let rows = stmt.query_map([], |row| {
6289        Ok(StoredEdge {
6290            source_file: row.get(0)?,
6291            source_symbol: row.get(1)?,
6292            target_file: row.get(2)?,
6293            target_symbol: row.get(3)?,
6294            kind: row.get(4)?,
6295            line: row.get::<_, i64>(5)? as u32,
6296        })
6297    })?;
6298    let mut edges = BTreeSet::new();
6299    for row in rows {
6300        edges.insert(row?);
6301    }
6302    Ok(edges)
6303}
6304
6305fn module_target_from_dependencies(
6306    project_root: &Path,
6307    dependencies: &BTreeSet<String>,
6308) -> Option<String> {
6309    dependencies.iter().find_map(|dep| {
6310        let path = project_root.join(dep);
6311        if path.is_file() {
6312            Some(relative_path(project_root, &canonicalize_path(&path)))
6313        } else {
6314            None
6315        }
6316    })
6317}
6318
6319fn reexport_index_from_raw(raw_ref: &RawRef, target_file: Option<String>) -> ReexportIndex {
6320    let mut named = HashMap::new();
6321    if let Some(full_ref) = &raw_ref.full_ref {
6322        named = parse_reexport_names(full_ref);
6323    }
6324    ReexportIndex {
6325        target_file,
6326        named,
6327        wildcard: raw_ref.wildcard,
6328    }
6329}
6330
6331fn parse_reexport_names(statement: &str) -> HashMap<String, String> {
6332    let mut names = HashMap::new();
6333    let Some(open) = statement.find('{') else {
6334        return names;
6335    };
6336    let Some(close) = statement[open + 1..]
6337        .find('}')
6338        .map(|offset| open + 1 + offset)
6339    else {
6340        return names;
6341    };
6342    for spec in statement[open + 1..close].split(',') {
6343        let spec = spec.trim();
6344        if spec.is_empty() {
6345            continue;
6346        }
6347        if let Some((source, local)) = spec.split_once(" as ") {
6348            names.insert(local.trim().to_string(), source.trim().to_string());
6349        } else {
6350            names.insert(spec.to_string(), spec.to_string());
6351        }
6352    }
6353    names
6354}
6355
6356fn dependencies_for_ref(
6357    tx: &Transaction<'_>,
6358    project_root: &Path,
6359    ref_id: &str,
6360) -> Result<BTreeSet<String>> {
6361    let row = tx.query_row(
6362        "SELECT kind, caller_file, module_path, target_file FROM refs WHERE ref_id = ?1",
6363        params![ref_id],
6364        |row| {
6365            Ok(RefDependencyRow {
6366                ref_id: ref_id.to_string(),
6367                kind: row.get(0)?,
6368                caller_file: row.get(1)?,
6369                module_path: row.get(2)?,
6370                target_file: row.get(3)?,
6371            })
6372        },
6373    )?;
6374
6375    match row.kind.as_str() {
6376        "import" | "reexport" => {
6377            let Some(module_path) = row.module_path.as_deref() else {
6378                return Ok(BTreeSet::new());
6379            };
6380            let file_deps = file_dependencies_for_file(tx, &row.caller_file)?;
6381            let module_deps =
6382                module_dependencies_for_ref(project_root, &row.caller_file, module_path);
6383            Ok(file_deps.intersection(&module_deps).cloned().collect())
6384        }
6385        "export_alias" => Ok(BTreeSet::new()),
6386        "call" => {
6387            let mut deps = file_dependencies_for_file(tx, &row.caller_file)?;
6388            if let Some(target_file) = row.target_file {
6389                deps.insert(target_file);
6390            }
6391            Ok(deps)
6392        }
6393        _ => file_dependencies_for_file(tx, &row.caller_file),
6394    }
6395}
6396
6397#[derive(Debug)]
6398struct RefDependencyRow {
6399    ref_id: String,
6400    kind: String,
6401    caller_file: String,
6402    module_path: Option<String>,
6403    target_file: Option<String>,
6404}
6405
6406fn ref_dependency_row_depends_on(
6407    project_root: &Path,
6408    row: &RefDependencyRow,
6409    rel_path: &str,
6410) -> bool {
6411    if row.target_file.as_deref() == Some(rel_path) {
6412        return true;
6413    }
6414
6415    match row.kind.as_str() {
6416        "call" => true,
6417        "import" | "reexport" => row
6418            .module_path
6419            .as_deref()
6420            .map(|module_path| {
6421                module_dependencies_for_ref(project_root, &row.caller_file, module_path)
6422                    .contains(rel_path)
6423            })
6424            .unwrap_or(false),
6425        "export_alias" => false,
6426        _ => false,
6427    }
6428}
6429
6430fn file_dependencies_for_file(tx: &Transaction<'_>, file_path: &str) -> Result<BTreeSet<String>> {
6431    let mut stmt = tx
6432        .prepare("SELECT dep_file FROM file_dependencies WHERE file_path = ?1 ORDER BY dep_file")?;
6433    let rows = stmt.query_map(params![file_path], |row| row.get::<_, String>(0))?;
6434    let mut deps = BTreeSet::new();
6435    for row in rows {
6436        deps.insert(row?);
6437    }
6438    Ok(deps)
6439}
6440
6441fn module_dependencies_for_ref(
6442    project_root: &Path,
6443    caller_file: &str,
6444    module_path: &str,
6445) -> BTreeSet<String> {
6446    module_dependencies(project_root, &project_root.join(caller_file), module_path)
6447}
6448
6449fn import_dependencies(
6450    project_root: &Path,
6451    abs_path: &Path,
6452    imports: &[ImportStatement],
6453) -> BTreeSet<String> {
6454    let mut deps = BTreeSet::new();
6455    for import in imports {
6456        deps.extend(module_dependencies(
6457            project_root,
6458            abs_path,
6459            &import.module_path,
6460        ));
6461    }
6462    deps
6463}
6464
6465fn module_dependencies(
6466    project_root: &Path,
6467    abs_path: &Path,
6468    module_path: &str,
6469) -> BTreeSet<String> {
6470    let mut deps = BTreeSet::new();
6471    let caller_dir = abs_path.parent().unwrap_or(project_root);
6472    if let Some(resolved) = callgraph::resolve_module_path(caller_dir, module_path) {
6473        deps.insert(relative_path(project_root, &resolved));
6474    }
6475    if module_path.starts_with('.') {
6476        let base = caller_dir.join(module_path);
6477        for candidate in relative_module_candidates(&base) {
6478            deps.insert(relative_path(project_root, &candidate));
6479        }
6480    }
6481    deps
6482}
6483
6484fn relative_module_candidates(base: &Path) -> Vec<PathBuf> {
6485    let mut candidates = Vec::new();
6486    if base.extension().is_some() {
6487        candidates.push(base.to_path_buf());
6488        return candidates;
6489    }
6490    for ext in JS_TS_EXTENSIONS {
6491        candidates.push(base.with_extension(ext));
6492    }
6493    for ext in JS_TS_EXTENSIONS {
6494        candidates.push(base.join(format!("index.{ext}")));
6495    }
6496    candidates
6497}
6498
6499fn import_local_names(import: &ImportStatement) -> Vec<String> {
6500    let mut names = Vec::new();
6501    if let Some(default) = &import.default_import {
6502        names.push(default.clone());
6503    }
6504    if let Some(namespace) = &import.namespace_import {
6505        names.push(namespace.clone());
6506    }
6507    for name in &import.names {
6508        names.push(crate::imports::specifier_local_name(name).to_string());
6509    }
6510    names
6511}
6512
6513fn import_requested_names(import: &ImportStatement) -> Vec<String> {
6514    import
6515        .names
6516        .iter()
6517        .map(|name| crate::imports::specifier_imported_name(name).to_string())
6518        .collect()
6519}
6520
6521fn import_is_wildcard(import: &ImportStatement) -> bool {
6522    import.namespace_import.is_some() || import.raw_text.contains('*')
6523}
6524
6525fn namespace_alias(full_ref: &str) -> Option<String> {
6526    full_ref
6527        .split_once('.')
6528        .map(|(namespace, _)| namespace.to_string())
6529}
6530
6531fn import_kind_label(kind: ImportKind) -> &'static str {
6532    match kind {
6533        ImportKind::Value => "value",
6534        ImportKind::Type => "type",
6535        ImportKind::SideEffect => "side_effect",
6536    }
6537}
6538
6539fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
6540    match kind {
6541        SymbolKind::Function => "function",
6542        SymbolKind::Class => "class",
6543        SymbolKind::Method => "method",
6544        SymbolKind::Struct => "struct",
6545        SymbolKind::Interface => "interface",
6546        SymbolKind::Enum => "enum",
6547        SymbolKind::TypeAlias => "type_alias",
6548        SymbolKind::Variable => "variable",
6549        SymbolKind::Heading => "heading",
6550        SymbolKind::FileSummary => "file_summary",
6551    }
6552}
6553
6554fn is_type_like(kind: &SymbolKind) -> bool {
6555    matches!(
6556        kind,
6557        SymbolKind::Class
6558            | SymbolKind::Struct
6559            | SymbolKind::Interface
6560            | SymbolKind::Enum
6561            | SymbolKind::TypeAlias
6562    )
6563}
6564
6565fn lang_label(lang: LangId) -> &'static str {
6566    match lang {
6567        LangId::TypeScript => "typescript",
6568        LangId::Tsx => "tsx",
6569        LangId::JavaScript => "javascript",
6570        LangId::Python => "python",
6571        LangId::Rust => "rust",
6572        LangId::Go => "go",
6573        LangId::C => "c",
6574        LangId::Cpp => "cpp",
6575        LangId::Zig => "zig",
6576        LangId::CSharp => "csharp",
6577        LangId::Bash => "bash",
6578        LangId::Html => "html",
6579        LangId::Markdown => "markdown",
6580        LangId::Solidity => "solidity",
6581        LangId::Scss => "scss",
6582        LangId::Vue => "vue",
6583        LangId::Json => "json",
6584        LangId::Scala => "scala",
6585        LangId::Java => "java",
6586        LangId::Ruby => "ruby",
6587        LangId::Kotlin => "kotlin",
6588        LangId::Swift => "swift",
6589        LangId::Php => "php",
6590        LangId::Lua => "lua",
6591        LangId::Perl => "perl",
6592        LangId::Yaml => "yaml",
6593        LangId::Pascal => "pascal",
6594        LangId::R => "r",
6595    }
6596}
6597
6598fn lang_from_label(label: &str) -> Option<LangId> {
6599    match label {
6600        "typescript" => Some(LangId::TypeScript),
6601        "tsx" => Some(LangId::Tsx),
6602        "javascript" => Some(LangId::JavaScript),
6603        "python" => Some(LangId::Python),
6604        "rust" => Some(LangId::Rust),
6605        "go" => Some(LangId::Go),
6606        "c" => Some(LangId::C),
6607        "cpp" => Some(LangId::Cpp),
6608        "zig" => Some(LangId::Zig),
6609        "csharp" => Some(LangId::CSharp),
6610        "bash" => Some(LangId::Bash),
6611        "html" => Some(LangId::Html),
6612        "markdown" => Some(LangId::Markdown),
6613        "solidity" => Some(LangId::Solidity),
6614        "scss" => Some(LangId::Scss),
6615        "vue" => Some(LangId::Vue),
6616        "json" => Some(LangId::Json),
6617        "scala" => Some(LangId::Scala),
6618        "java" => Some(LangId::Java),
6619        "ruby" => Some(LangId::Ruby),
6620        "kotlin" => Some(LangId::Kotlin),
6621        "swift" => Some(LangId::Swift),
6622        "php" => Some(LangId::Php),
6623        "lua" => Some(LangId::Lua),
6624        "perl" => Some(LangId::Perl),
6625        "yaml" => Some(LangId::Yaml),
6626        "pascal" => Some(LangId::Pascal),
6627        "r" => Some(LangId::R),
6628        _ => None,
6629    }
6630}
6631
6632fn normalize_file_list(project_root: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
6633    let mut normalized = if files.is_empty() {
6634        callgraph::walk_project_files(project_root).collect::<Vec<_>>()
6635    } else {
6636        files
6637            .iter()
6638            .map(|path| normalize_file_path(project_root, path))
6639            .collect::<Result<Vec<_>>>()?
6640    };
6641    normalized.sort();
6642    normalized.dedup();
6643    Ok(normalized)
6644}
6645
6646fn normalize_file_path(project_root: &Path, path: &Path) -> Result<PathBuf> {
6647    let full_path = if path.is_relative() {
6648        project_root.join(path)
6649    } else {
6650        path.to_path_buf()
6651    };
6652    Ok(canonicalize_path(&full_path))
6653}
6654
6655fn canonicalize_path(path: &Path) -> PathBuf {
6656    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
6657}
6658
6659fn relative_path(project_root: &Path, path: &Path) -> String {
6660    if let Ok(stripped) = path.strip_prefix(project_root) {
6661        return stripped.to_string_lossy().replace('\\', "/");
6662    }
6663    let canon_root = canonicalize_path(project_root);
6664    let canon_path = canonicalize_path(path);
6665    if let Ok(stripped) = canon_path.strip_prefix(&canon_root) {
6666        return stripped.to_string_lossy().replace('\\', "/");
6667    }
6668    canon_path.to_string_lossy().replace('\\', "/")
6669}
6670
6671fn unqualified_name(scoped: &str) -> &str {
6672    if scoped == TOP_LEVEL_SYMBOL {
6673        return scoped;
6674    }
6675    scoped
6676        .rsplit("::")
6677        .next()
6678        .unwrap_or(scoped)
6679        .rsplit('.')
6680        .next()
6681        .unwrap_or(scoped)
6682        .rsplit('#')
6683        .next()
6684        .unwrap_or(scoped)
6685}
6686
6687fn ref_id(parts: &[&str]) -> String {
6688    let joined = parts.join("\0");
6689    hash_to_hex(blake3::hash(joined.as_bytes()))
6690}
6691
6692fn hash_to_hex(hash: blake3::Hash) -> String {
6693    hash.to_hex().to_string()
6694}
6695
6696fn hash_from_hex(value: &str) -> Option<blake3::Hash> {
6697    let bytes = hex_to_bytes(value)?;
6698    Some(blake3::Hash::from_bytes(bytes))
6699}
6700
6701fn hex_to_bytes(value: &str) -> Option<[u8; 32]> {
6702    if value.len() != 64 {
6703        return None;
6704    }
6705    let mut bytes = [0u8; 32];
6706    for (index, slot) in bytes.iter_mut().enumerate() {
6707        let start = index * 2;
6708        let end = start + 2;
6709        *slot = u8::from_str_radix(&value[start..end], 16).ok()?;
6710    }
6711    Some(bytes)
6712}
6713
6714#[derive(Debug, Clone)]
6715struct LineIndex {
6716    newline_offsets: Vec<usize>,
6717    source_len: usize,
6718}
6719
6720impl LineIndex {
6721    fn new(source: &str) -> Self {
6722        Self {
6723            newline_offsets: source
6724                .bytes()
6725                .enumerate()
6726                .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset))
6727                .collect(),
6728            source_len: source.len(),
6729        }
6730    }
6731
6732    fn byte_to_line(&self, byte_offset: usize) -> u32 {
6733        let byte_offset = byte_offset.min(self.source_len);
6734        self.newline_offsets
6735            .partition_point(|offset| *offset < byte_offset) as u32
6736            + 1
6737    }
6738}
6739
6740fn empty_to_none(value: String) -> Option<String> {
6741    if value.is_empty() {
6742        None
6743    } else {
6744        Some(value)
6745    }
6746}
6747
6748fn bool_int(value: bool) -> i64 {
6749    if value {
6750        1
6751    } else {
6752        0
6753    }
6754}
6755
6756fn system_time_to_ns(time: SystemTime) -> i64 {
6757    time.duration_since(UNIX_EPOCH)
6758        .unwrap_or_default()
6759        .as_nanos()
6760        .min(i64::MAX as u128) as i64
6761}
6762
6763fn ns_to_system_time(value: i64) -> SystemTime {
6764    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
6765}
6766
6767fn unix_seconds_now() -> i64 {
6768    SystemTime::now()
6769        .duration_since(UNIX_EPOCH)
6770        .unwrap_or_default()
6771        .as_secs() as i64
6772}
6773
6774#[cfg(test)]
6775mod cold_build_insert_tests {
6776    use super::*;
6777    use crate::imports::ImportBlock;
6778    use std::fs;
6779    use tempfile::tempdir;
6780
6781    #[test]
6782    fn depth_boundary_counts_match_full_fetch_lengths_with_dangling_edges() {
6783        let dir = tempdir().expect("temp dir");
6784        let file = dir.path().join("main.ts");
6785        fs::write(
6786            &file,
6787            r#"export function topA() {
6788  root();
6789}
6790
6791export function topB() {
6792  root();
6793}
6794
6795export function root() {
6796  leaf();
6797  missing();
6798}
6799
6800export function leaf() {}
6801"#,
6802        )
6803        .expect("write fixture");
6804
6805        let store = CallGraphStore::open(
6806            dir.path().join(".store-depth-boundary-counts"),
6807            dir.path().to_path_buf(),
6808        )
6809        .expect("open store");
6810        store
6811            .cold_build(std::slice::from_ref(&file))
6812            .expect("cold build");
6813
6814        let root = store
6815            .node_for(Path::new("main.ts"), "root")
6816            .expect("root node");
6817        let leaf = store
6818            .node_for(Path::new("main.ts"), "leaf")
6819            .expect("leaf node");
6820
6821        let (full_forward_len, full_direct_len) = {
6822            let conn = store.conn.lock().expect("callgraph store mutex poisoned");
6823            conn.execute(
6824                "INSERT INTO edges (
6825                    edge_id, ref_id, source_node, target_node, target_file,
6826                    target_symbol, kind, line, provenance
6827                 ) VALUES (
6828                    'dangling-forward-boundary', 'missing-forward-ref', ?1, NULL,
6829                    ?2, ?3, 'call', 98, ?4
6830                 )",
6831                rusqlite::params![
6832                    &root.node_id,
6833                    &leaf.file,
6834                    &leaf.symbol,
6835                    PROVENANCE_TREESITTER
6836                ],
6837            )
6838            .expect("insert dangling forward edge");
6839            conn.execute(
6840                "INSERT INTO edges (
6841                    edge_id, ref_id, source_node, target_node, target_file,
6842                    target_symbol, kind, line, provenance
6843                 ) VALUES (
6844                    'dangling-direct-boundary', 'missing-direct-ref', 'missing-source-node',
6845                    ?1, ?2, ?3, 'call', 99, ?4
6846                 )",
6847                rusqlite::params![
6848                    &root.node_id,
6849                    &root.file,
6850                    &root.symbol,
6851                    PROVENANCE_TREESITTER
6852                ],
6853            )
6854            .expect("insert dangling direct-caller edge");
6855
6856            let full_forward_len = forward_calls_for_node(&conn, &root)
6857                .expect("full forward calls")
6858                .len();
6859            let counted_forward_len =
6860                forward_call_count_for_node(&conn, &root).expect("counted forward calls");
6861            assert_eq!(
6862                counted_forward_len, full_forward_len,
6863                "forward boundary COUNT must mirror outgoing_calls_for_node + unresolved_calls_for_node"
6864            );
6865
6866            let full_direct_len = direct_callers_for_tuple(&conn, &root.file, &root.symbol)
6867                .expect("full direct callers")
6868                .len();
6869            let counted_direct_len = direct_caller_count_for_tuple(&conn, &root.file, &root.symbol)
6870                .expect("counted direct callers");
6871            assert_eq!(
6872                counted_direct_len, full_direct_len,
6873                "direct-caller boundary COUNT must mirror direct_callers_for_tuple"
6874            );
6875
6876            (full_forward_len, full_direct_len)
6877        };
6878
6879        assert_eq!(
6880            full_forward_len, 2,
6881            "fixture root should have one resolved and one unresolved outgoing call"
6882        );
6883        assert_eq!(
6884            full_direct_len, 2,
6885            "fixture root should have two real direct callers"
6886        );
6887
6888        let tree = store
6889            .call_tree(Path::new("main.ts"), "root", 0)
6890            .expect("call tree");
6891        assert!(tree.depth_limited);
6892        assert_eq!(tree.children.len(), 0);
6893        assert_eq!(
6894            tree.truncated, full_forward_len,
6895            "call_tree depth boundary must report the full forward-call list length"
6896        );
6897
6898        let callers = store
6899            .callers_of(Path::new("main.ts"), "leaf", 0)
6900            .expect("callers");
6901        assert!(callers.depth_limited);
6902        assert_eq!(callers.callers.len(), 1);
6903        assert_eq!(callers.callers[0].caller.symbol, "root");
6904        assert_eq!(
6905            callers.truncated, full_direct_len,
6906            "callers depth boundary must report the full direct-caller list length"
6907        );
6908    }
6909
6910    #[test]
6911    fn source_freshness_matches_cache_collect_for_same_bytes() {
6912        let dir = tempdir().expect("temp dir");
6913        let path = dir.path().join("fixture.ts");
6914        let source = "export function main() { return helper(); }\n";
6915        fs::write(&path, source).expect("write fixture");
6916
6917        let expected = cache_freshness::collect(&path).expect("collect freshness from file");
6918        let actual =
6919            collect_source_freshness(&path, source).expect("collect freshness from source");
6920
6921        assert_eq!(actual, expected);
6922    }
6923
6924    #[test]
6925    fn cold_build_prepared_bulk_insert_matches_reference_rows() {
6926        let dir = tempdir().expect("temp dir");
6927        let project_root = dir.path();
6928        let extract = fixture_extract(project_root);
6929        let resolved = fixture_resolved(&extract);
6930
6931        let reference = build_reference_connection(project_root, &extract, &resolved);
6932        let optimized = build_optimized_connection(project_root, &extract, &resolved);
6933
6934        for table in [
6935            "files",
6936            "nodes",
6937            "file_dependencies",
6938            "dispatch_hints",
6939            "refs",
6940            "edges",
6941        ] {
6942            assert_eq!(table_rows(&reference, table), table_rows(&optimized, table),);
6943        }
6944        assert_eq!(
6945            backend_state_rows(&reference),
6946            backend_state_rows(&optimized),
6947            "backend freshness rows must match apart from updated_at"
6948        );
6949        assert_eq!(secondary_indexes(&reference), secondary_indexes(&optimized));
6950    }
6951
6952    #[test]
6953    fn cold_build_chunked_matches_unchunked_logical_rows() {
6954        let dir = tempdir().expect("temp dir");
6955        let project_root = fs::canonicalize(dir.path()).expect("canonical temp root");
6956        write_chunked_equivalence_fixture(&project_root);
6957        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
6958        assert!(
6959            files.len() > 6,
6960            "fixture should be large enough to split into multiple chunks"
6961        );
6962
6963        let unchunked = CallGraphStore::open(
6964            project_root.join(".store-unchunked"),
6965            project_root.to_path_buf(),
6966        )
6967        .expect("open unchunked store");
6968        let unchunked_stats = unchunked
6969            .cold_build_chunked(&files, 0)
6970            .expect("unchunked cold build");
6971
6972        let chunked = CallGraphStore::open(
6973            project_root.join(".store-chunked"),
6974            project_root.to_path_buf(),
6975        )
6976        .expect("open chunked store");
6977        let chunked_stats = chunked
6978            .cold_build_chunked(&files, 3)
6979            .expect("chunked cold build");
6980
6981        assert_cold_build_stats_match_except_elapsed(&unchunked_stats, &chunked_stats);
6982        assert_eq!(
6983            unchunked.edge_snapshot().expect("unchunked edge snapshot"),
6984            chunked.edge_snapshot().expect("chunked edge snapshot"),
6985            "public edge snapshots must match"
6986        );
6987
6988        let dispatch_edges = {
6989            let conn = chunked.conn.lock().expect("callgraph store mutex poisoned");
6990            conn.query_row(
6991                "SELECT COUNT(*) FROM edges WHERE provenance IN ('name_match', 'type_match')",
6992                [],
6993                |row| row.get::<_, i64>(0),
6994            )
6995            .expect("count dispatch edges")
6996        };
6997        assert!(
6998            dispatch_edges > 0,
6999            "fixture must exercise method-dispatch edge insertion"
7000        );
7001
7002        for table in [
7003            "edges",
7004            "refs",
7005            "nodes",
7006            "file_dependencies",
7007            "dispatch_hints",
7008        ] {
7009            assert_eq!(
7010                graph_table_rows(&unchunked, table),
7011                graph_table_rows(&chunked, table),
7012                "chunked cold build must match unchunked rows for {table}"
7013            );
7014        }
7015        assert_eq!(
7016            graph_table_rows_without(&unchunked, "files", &["indexed_at"]),
7017            graph_table_rows_without(&chunked, "files", &["indexed_at"]),
7018            "files rows must match apart from indexed_at"
7019        );
7020        assert_eq!(
7021            graph_table_rows_without(&unchunked, "backend_file_state", &["updated_at"]),
7022            graph_table_rows_without(&chunked, "backend_file_state", &["updated_at"]),
7023            "backend freshness rows must match apart from updated_at"
7024        );
7025
7026        let published_dir = project_root.join(".store-published");
7027        let (_published, _stats) = CallGraphStore::cold_build_with_lease_chunked(
7028            published_dir.clone(),
7029            project_root.to_path_buf(),
7030            &files,
7031            0,
7032        )
7033        .expect("published unchunked cold build");
7034        assert!(
7035            !CallGraphStore::needs_cold_build(&published_dir, &project_root)
7036                .expect("needs_cold_build after publish"),
7037            "published store should be ready"
7038        );
7039        let (_opened, rebuild_stats) = CallGraphStore::ensure_built_with_lease_chunked(
7040            published_dir,
7041            project_root.to_path_buf(),
7042            &files,
7043            3,
7044        )
7045        .expect("ensure with a different chunk size");
7046        assert!(
7047            rebuild_stats.is_none(),
7048            "changing callgraph_chunk_size must not affect store identity or force a rebuild"
7049        );
7050    }
7051
7052    // Perf A/B bench (not a gate): measures cold_build wall time at a given
7053    // chunk size against a real repo. Driven by env so the same binary can A/B
7054    // chunk=0 vs chunk=N in clean isolation. Reusable for the deferred DB-spill
7055    // memory work. Run:
7056    //   AFT_PERF_REPO=/path AFT_PERF_CHUNK=0 cargo test -p agent-file-tools \
7057    //     --release --lib bench_cold_build_chunk -- --ignored --nocapture
7058    #[test]
7059    #[ignore]
7060    fn bench_cold_build_chunk() {
7061        let repo = std::env::var("AFT_PERF_REPO").expect("AFT_PERF_REPO");
7062        let chunk: usize = std::env::var("AFT_PERF_CHUNK")
7063            .expect("AFT_PERF_CHUNK")
7064            .parse()
7065            .expect("AFT_PERF_CHUNK must be a non-negative integer");
7066        let project_root = fs::canonicalize(&repo).expect("canonical repo root");
7067        let files = callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
7068        let dir = tempdir().expect("temp dir");
7069        let store = CallGraphStore::open(dir.path().join(".store"), project_root.clone())
7070            .expect("open store");
7071        let started = Instant::now();
7072        let stats = store.cold_build_chunked(&files, chunk).expect("cold build");
7073        let ms = started.elapsed().as_millis();
7074        println!(
7075            "BENCH_COLD_BUILD chunk={chunk} files={} nodes={} refs={} edges={} ms={ms}",
7076            stats.files, stats.nodes, stats.refs, stats.edges
7077        );
7078    }
7079
7080    #[test]
7081    fn incremental_barrel_refresh_matches_per_ref_lookup_and_cold_rebuild() {
7082        let dir = tempdir().expect("temp dir");
7083        let project_root = dir.path();
7084        let files =
7085            write_barrel_refresh_fixture(project_root, "export { target } from \"./target\";\n");
7086        let index_path = project_root.join("src/index.ts");
7087
7088        let store = CallGraphStore::open(
7089            project_root.join(".store-incremental-barrel"),
7090            project_root.to_path_buf(),
7091        )
7092        .expect("open incremental store");
7093        store.cold_build(&files).expect("initial cold build");
7094
7095        {
7096            let mut conn = store.conn.lock().expect("callgraph store mutex poisoned");
7097            let tx = conn.transaction().expect("dependency transaction");
7098            let dependent_refs = ref_ids_depending_on(&tx, project_root, "src/index.ts")
7099                .expect("dependent refs for barrel");
7100            let selected_ref_ids = dependent_refs
7101                .iter()
7102                .map(|dependent_ref| dependent_ref.ref_id.clone())
7103                .collect::<BTreeSet<_>>();
7104            let mut threaded_ref_ids = BTreeSet::new();
7105            let mut threaded_by_caller = BTreeMap::new();
7106            record_dependent_refs(
7107                &mut threaded_ref_ids,
7108                &mut threaded_by_caller,
7109                dependent_refs,
7110            );
7111            let old_by_caller = refs_by_caller_for_ref_ids(&tx, &selected_ref_ids)
7112                .expect("old per-ref caller lookup");
7113
7114            assert_eq!(threaded_ref_ids, selected_ref_ids);
7115            assert_eq!(threaded_by_caller, old_by_caller);
7116            for consumer in [
7117                "src/consumer_a.ts",
7118                "src/consumer_b.ts",
7119                "src/consumer_c.ts",
7120            ] {
7121                assert!(
7122                    threaded_by_caller.contains_key(consumer),
7123                    "barrel edit should select dependent refs from {consumer}"
7124                );
7125            }
7126        }
7127
7128        fs::write(
7129            &index_path,
7130            "export { target } from \"./target\";\nexport function extra() { return 1; }\n",
7131        )
7132        .expect("edit barrel");
7133        let stats = store
7134            .refresh_files(std::slice::from_ref(&index_path))
7135            .expect("incremental refresh");
7136        assert_eq!(stats.surface_changed, vec!["src/index.ts".to_string()]);
7137        assert!(
7138            stats.dependency_selected_refs > 0,
7139            "barrel surface edit should select dependent refs"
7140        );
7141
7142        let cold_store = CallGraphStore::open(
7143            project_root.join(".store-cold-barrel"),
7144            project_root.to_path_buf(),
7145        )
7146        .expect("open cold rebuild store");
7147        cold_store
7148            .cold_build(&files)
7149            .expect("comparison cold build");
7150
7151        for table in [
7152            "nodes",
7153            "refs",
7154            "file_dependencies",
7155            "edges",
7156            "dispatch_hints",
7157        ] {
7158            assert_eq!(
7159                graph_table_rows(&store, table),
7160                graph_table_rows(&cold_store, table),
7161                "incremental refresh {table} rows must match cold rebuild"
7162            );
7163        }
7164    }
7165
7166    fn build_reference_connection(
7167        project_root: &Path,
7168        extract: &FileExtract,
7169        resolved: &ResolvedRef,
7170    ) -> Connection {
7171        let mut conn = Connection::open_in_memory().expect("open reference db");
7172        configure_build_connection(&conn).expect("configure reference db");
7173        initialize_schema(&conn).expect("initialize reference schema");
7174        {
7175            let tx = conn.transaction().expect("reference transaction");
7176            clear_tables(&tx).expect("reference clear");
7177            insert_meta(&tx).expect("reference meta");
7178            insert_file_extract(&tx, project_root, extract).expect("reference file extract");
7179            insert_resolved_ref(&tx, resolved).expect("reference resolved ref");
7180            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7181                .expect("reference dispatch edges");
7182            assert_eq!(supplemental, 0);
7183            tx.commit().expect("reference commit");
7184        }
7185        conn
7186    }
7187
7188    fn build_optimized_connection(
7189        project_root: &Path,
7190        extract: &FileExtract,
7191        resolved: &ResolvedRef,
7192    ) -> Connection {
7193        let mut conn = Connection::open_in_memory().expect("open optimized db");
7194        configure_build_connection(&conn).expect("configure optimized db");
7195        initialize_schema(&conn).expect("initialize optimized schema");
7196        {
7197            let tx = conn.transaction().expect("optimized transaction");
7198            clear_tables(&tx).expect("optimized clear");
7199            insert_meta(&tx).expect("optimized meta");
7200            drop_cold_build_secondary_indexes(&tx).expect("drop secondary indexes");
7201            {
7202                let workspace_root = project_root.display().to_string();
7203                let mut inserts = ColdBuildInsertStatements::new(&tx).expect("prepare inserts");
7204                insert_file_extract_prepared(&mut inserts, &workspace_root, extract)
7205                    .expect("optimized file extract");
7206                insert_resolved_ref_prepared(&mut inserts, resolved)
7207                    .expect("optimized resolved ref");
7208            }
7209            create_cold_build_secondary_indexes(&tx).expect("create secondary indexes");
7210            let supplemental = insert_method_dispatch_edges(&tx, project_root, None)
7211                .expect("optimized dispatch edges");
7212            assert_eq!(supplemental, 0);
7213            tx.commit().expect("optimized commit");
7214        }
7215        conn
7216    }
7217
7218    fn fixture_extract(project_root: &Path) -> FileExtract {
7219        let rel_path = "src/main.ts".to_string();
7220        let target_path = "src/helper.ts".to_string();
7221        let node = NodeRecord {
7222            id: "node-main".to_string(),
7223            file_path: rel_path.clone(),
7224            name: "main".to_string(),
7225            scoped_name: "main".to_string(),
7226            kind: "function".to_string(),
7227            range: Range {
7228                start_line: 0,
7229                start_col: 0,
7230                end_line: 0,
7231                end_col: 32,
7232            },
7233            range_ordinal: 0,
7234            signature: Some("export function main()".to_string()),
7235            exported: true,
7236            is_default_export: false,
7237            is_type_like: false,
7238            is_callgraph_entry_point: true,
7239        };
7240        let mut dependencies = BTreeSet::new();
7241        dependencies.insert(target_path.clone());
7242        let raw_ref = RawRef {
7243            ref_id: "ref-main-helper".to_string(),
7244            caller_node: Some(node.id.clone()),
7245            caller_symbol: Some(node.scoped_name.clone()),
7246            caller_file: rel_path.clone(),
7247            kind: "call".to_string(),
7248            short_name: Some("helper".to_string()),
7249            full_ref: Some("helper".to_string()),
7250            module_path: None,
7251            import_kind: None,
7252            local_name: Some("helper".to_string()),
7253            requested_name: Some("helper".to_string()),
7254            namespace_alias: None,
7255            wildcard: false,
7256            line: 1,
7257            byte_start: 24,
7258            byte_end: 32,
7259            dependencies,
7260        };
7261        FileExtract {
7262            abs_path: project_root.join(&rel_path),
7263            rel_path,
7264            freshness: FileFreshness {
7265                mtime: UNIX_EPOCH + Duration::from_secs(123),
7266                size: 40,
7267                content_hash: cache_freshness::hash_bytes(b"fixture source"),
7268            },
7269            lang: LangId::TypeScript,
7270            data: FileCallData {
7271                calls_by_symbol: HashMap::new(),
7272                exported_symbols: Vec::new(),
7273                symbol_metadata: HashMap::new(),
7274                default_export_symbol: None,
7275                import_block: ImportBlock::empty(),
7276                lang: LangId::TypeScript,
7277            },
7278            nodes: vec![node.clone()],
7279            raw_refs: vec![raw_ref],
7280            dispatch_hints: vec![DispatchHint {
7281                id: "dispatch-main-helper".to_string(),
7282                method_name: "helper".to_string(),
7283                caller_node: node.id,
7284                file: "src/main.ts".to_string(),
7285                line: 1,
7286                byte_start: 24,
7287                byte_end: 32,
7288            }],
7289            surface_fingerprint: "surface".to_string(),
7290        }
7291    }
7292
7293    fn fixture_resolved(extract: &FileExtract) -> ResolvedRef {
7294        let raw = extract.raw_refs[0].clone();
7295        let mut dependencies = raw.dependencies.clone();
7296        dependencies.insert("src/helper.ts".to_string());
7297        ResolvedRef {
7298            edge: Some(EdgeRecord {
7299                edge_id: "edge-main-helper".to_string(),
7300                source_node: raw.caller_node.clone().expect("caller node"),
7301                target_node: Some("node-helper".to_string()),
7302                target_file: "src/helper.ts".to_string(),
7303                target_symbol: "helper".to_string(),
7304                kind: "call".to_string(),
7305                line: raw.line,
7306            }),
7307            raw,
7308            status: "resolved".to_string(),
7309            target_node: Some("node-helper".to_string()),
7310            target_file: Some("src/helper.ts".to_string()),
7311            target_symbol: Some("helper".to_string()),
7312            dependencies,
7313        }
7314    }
7315
7316    fn write_chunked_equivalence_fixture(project_root: &Path) {
7317        let ts_dir = project_root.join("ts");
7318        fs::create_dir_all(&ts_dir).expect("create ts dir");
7319        fs::write(
7320            ts_dir.join("leaf.ts"),
7321            "export function leaf(value: number) {\n  return value + 1;\n}\n",
7322        )
7323        .expect("write ts leaf");
7324        fs::write(
7325            ts_dir.join("mid.ts"),
7326            "import { leaf } from './leaf';\n\nexport function mid(value: number) {\n  return leaf(value);\n}\n",
7327        )
7328        .expect("write ts mid");
7329        fs::write(
7330            ts_dir.join("entry.ts"),
7331            "import { mid } from './mid';\nimport { Worker } from './worker';\n\nexport function entry(worker: Worker) {\n  return mid(worker.run());\n}\n",
7332        )
7333        .expect("write ts entry");
7334        fs::write(
7335            ts_dir.join("worker.ts"),
7336            "export class Worker {\n  run() {\n    return 41;\n  }\n}\n",
7337        )
7338        .expect("write ts worker");
7339        for idx in 0..4 {
7340            fs::write(
7341                ts_dir.join(format!("extra_{idx}.ts")),
7342                format!(
7343                    "import {{ entry }} from './entry';\nimport {{ Worker }} from './worker';\n\nexport function extra{idx}() {{\n  return entry(new Worker());\n}}\n"
7344                ),
7345            )
7346            .expect("write ts extra");
7347        }
7348
7349        let rust_dir = project_root.join("src");
7350        let commands_dir = rust_dir.join("commands");
7351        fs::create_dir_all(&commands_dir).expect("create rust commands dir");
7352        fs::write(
7353            rust_dir.join("context.rs"),
7354            r#"pub struct AppContext;
7355
7356impl AppContext {
7357    pub fn callgraph_store_for_ops(&self) -> usize {
7358        1
7359    }
7360}
7361"#,
7362        )
7363        .expect("write rust context");
7364        fs::write(
7365            rust_dir.join("lib.rs"),
7366            "pub mod context;\npub mod commands;\n",
7367        )
7368        .expect("write rust lib");
7369        fs::write(
7370            commands_dir.join("mod.rs"),
7371            "pub mod callers;\npub mod impact;\npub mod trace_to;\n",
7372        )
7373        .expect("write rust commands mod");
7374        for name in ["callers", "impact", "trace_to"] {
7375            fs::write(
7376                commands_dir.join(format!("{name}.rs")),
7377                format!(
7378                    r#"use crate::context::AppContext;
7379
7380pub fn handle_{name}(ctx: &AppContext) -> usize {{
7381    ctx.callgraph_store_for_ops()
7382}}
7383"#
7384                ),
7385            )
7386            .expect("write rust command");
7387        }
7388    }
7389
7390    fn write_barrel_refresh_fixture(project_root: &Path, barrel_source: &str) -> Vec<PathBuf> {
7391        let src_dir = project_root.join("src");
7392        fs::create_dir_all(&src_dir).expect("create src dir");
7393
7394        let target_path = src_dir.join("target.ts");
7395        fs::write(&target_path, "export function target() {\n  return 1;\n}\n")
7396            .expect("write target");
7397
7398        let index_path = src_dir.join("index.ts");
7399        fs::write(&index_path, barrel_source).expect("write barrel");
7400
7401        let mut files = vec![target_path, index_path];
7402        for (file_name, function_name) in [
7403            ("consumer_a.ts", "consumerA"),
7404            ("consumer_b.ts", "consumerB"),
7405            ("consumer_c.ts", "consumerC"),
7406        ] {
7407            let path = src_dir.join(file_name);
7408            fs::write(
7409                &path,
7410                format!(
7411                    "import {{ target }} from \"./index\";\n\nexport function {function_name}() {{\n  return target();\n}}\n"
7412                ),
7413            )
7414            .expect("write consumer");
7415            files.push(path);
7416        }
7417        files
7418    }
7419
7420    fn graph_table_rows(store: &CallGraphStore, table: &str) -> Vec<String> {
7421        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7422        table_rows(&conn, table)
7423    }
7424
7425    fn graph_table_rows_without(
7426        store: &CallGraphStore,
7427        table: &str,
7428        excluded_columns: &[&str],
7429    ) -> Vec<String> {
7430        let conn = store.conn.lock().expect("callgraph store mutex poisoned");
7431        table_rows_without(&conn, table, excluded_columns)
7432    }
7433
7434    fn table_rows(conn: &Connection, table: &str) -> Vec<String> {
7435        table_rows_without(conn, table, &[])
7436    }
7437
7438    fn table_rows_without(
7439        conn: &Connection,
7440        table: &str,
7441        excluded_columns: &[&str],
7442    ) -> Vec<String> {
7443        let excluded_columns = excluded_columns.iter().copied().collect::<BTreeSet<_>>();
7444        let columns: Vec<String> = conn
7445            .prepare(&format!("PRAGMA table_info({table})"))
7446            .expect("prepare table_info")
7447            .query_map([], |row| row.get::<_, String>(1))
7448            .expect("query table_info")
7449            .collect::<std::result::Result<Vec<String>, _>>()
7450            .expect("collect columns")
7451            .into_iter()
7452            .filter(|column| !excluded_columns.contains(column.as_str()))
7453            .collect();
7454        let sql = format!(
7455            "SELECT {} FROM {table} ORDER BY {}",
7456            columns.join(", "),
7457            columns.join(", ")
7458        );
7459        conn.prepare(&sql)
7460            .expect("prepare table rows")
7461            .query_map([], |row| row_to_strings(row, columns.len()))
7462            .expect("query table rows")
7463            .collect::<std::result::Result<_, _>>()
7464            .expect("collect table rows")
7465    }
7466
7467    fn assert_cold_build_stats_match_except_elapsed(
7468        expected: &ColdBuildStats,
7469        actual: &ColdBuildStats,
7470    ) {
7471        assert_eq!(actual.files, expected.files, "file counts must match");
7472        assert_eq!(actual.nodes, expected.nodes, "node counts must match");
7473        assert_eq!(actual.refs, expected.refs, "ref counts must match");
7474        assert_eq!(actual.edges, expected.edges, "edge counts must match");
7475        assert_eq!(
7476            actual.failed_files.iter().cloned().collect::<BTreeSet<_>>(),
7477            expected
7478                .failed_files
7479                .iter()
7480                .cloned()
7481                .collect::<BTreeSet<_>>(),
7482            "failed file sets must match"
7483        );
7484    }
7485
7486    fn backend_state_rows(conn: &Connection) -> Vec<String> {
7487        conn.prepare(
7488            "SELECT backend, workspace_root, file_path, content_hash, status
7489             FROM backend_file_state
7490             ORDER BY backend, workspace_root, file_path, content_hash, status",
7491        )
7492        .expect("prepare backend rows")
7493        .query_map([], |row| row_to_strings(row, 5))
7494        .expect("query backend rows")
7495        .collect::<std::result::Result<_, _>>()
7496        .expect("collect backend rows")
7497    }
7498
7499    fn secondary_indexes(conn: &Connection) -> Vec<String> {
7500        let mut indexes = Vec::new();
7501        for table in [
7502            "files",
7503            "nodes",
7504            "refs",
7505            "file_dependencies",
7506            "edges",
7507            "dispatch_hints",
7508            "type_ref_names",
7509            "backend_file_state",
7510            "meta",
7511        ] {
7512            let sql = format!("PRAGMA index_list({table})");
7513            let mut stmt = conn.prepare(&sql).expect("prepare index list");
7514            let rows = stmt
7515                .query_map([], |row| row.get::<_, String>(1))
7516                .expect("query index list");
7517            for name in rows {
7518                let name = name.expect("index name");
7519                if name.starts_with("idx_") {
7520                    indexes.push(format!("{table}:{name}"));
7521                }
7522            }
7523        }
7524        indexes.sort();
7525        indexes
7526    }
7527
7528    fn row_to_strings(row: &rusqlite::Row<'_>, len: usize) -> rusqlite::Result<String> {
7529        let mut values = Vec::with_capacity(len);
7530        for index in 0..len {
7531            let value = row.get_ref(index)?;
7532            values.push(match value {
7533                rusqlite::types::ValueRef::Null => "NULL".to_string(),
7534                rusqlite::types::ValueRef::Integer(value) => value.to_string(),
7535                rusqlite::types::ValueRef::Real(value) => value.to_string(),
7536                rusqlite::types::ValueRef::Text(value) => {
7537                    String::from_utf8_lossy(value).into_owned()
7538                }
7539                rusqlite::types::ValueRef::Blob(value) => format!("{value:?}"),
7540            });
7541        }
7542        Ok(values.join("\u{1f}"))
7543    }
7544}
7545
7546#[cfg(test)]
7547mod build_pool_tests {
7548    use super::build_pool_size;
7549
7550    #[test]
7551    fn build_pool_is_bounded_to_half_cores_capped_at_eight() {
7552        let size = build_pool_size();
7553        // Never zero, never the full core count, never above the 8 cap — this is
7554        // the starvation guard for the cold-build's all-cores tree-sitter pass.
7555        assert!(size >= 1, "pool size must be at least 1");
7556        assert!(size <= 8, "pool size must be capped at 8, got {size}");
7557
7558        let cores = std::thread::available_parallelism()
7559            .map(|p| p.get())
7560            .unwrap_or(1);
7561        let expected = cores.div_ceil(2).clamp(1, 8);
7562        assert_eq!(size, expected, "pool size must be div_ceil(2).clamp(1,8)");
7563    }
7564}
7565
7566#[cfg(test)]
7567mod method_dispatch_inference_tests {
7568    use super::*;
7569    use std::fs;
7570    use tempfile::tempdir;
7571
7572    #[test]
7573    fn java_field_receiver_type_selects_declared_class_method() {
7574        let source = r#"class EntryPoint {
7575    private UserService userService;
7576
7577    void handle() {
7578        userService.find();
7579    }
7580}
7581
7582class UserService {
7583    void find() {}
7584}
7585
7586class AuditService {
7587    void find() {}
7588}
7589"#;
7590        let dir = tempdir().expect("temp dir");
7591        let root = dir.path();
7592        write_fixture(root, "src/EntryPoint.java", source);
7593        let reference = reference(
7594            "java",
7595            "src/EntryPoint.java",
7596            "EntryPoint::handle",
7597            "userService",
7598            "find",
7599            line_of(source, "userService.find()"),
7600        );
7601        let mut cache = DispatchSourceCache::new();
7602
7603        let receiver_type =
7604            infer_receiver_type(root, &reference, &mut cache).expect("receiver type");
7605        assert_eq!(receiver_type, "UserService");
7606
7607        let candidates = vec![
7608            method_candidate("audit", "AuditService::find"),
7609            method_candidate("user", "UserService::find"),
7610        ];
7611        let selected = select_type_match_candidate(&reference, &candidates, &receiver_type)
7612            .expect("type candidate");
7613        assert_eq!(selected.scoped_name, "UserService::find");
7614
7615        let wrong_candidates = vec![method_candidate("audit", "AuditService::find")];
7616        assert!(
7617            select_type_match_candidate(&reference, &wrong_candidates, &receiver_type).is_none()
7618        );
7619    }
7620
7621    #[test]
7622    fn kotlin_property_and_local_value_types_are_inferred() {
7623        let source = r#"class Handler {
7624    private val auditService: AuditService = AuditService()
7625
7626    fun handle() {
7627        auditService.find()
7628        val userService: UserService = UserService()
7629        userService.find()
7630        val billingService = BillingService()
7631        billingService.find()
7632    }
7633}
7634
7635class UserService { fun find() {} }
7636class AuditService { fun find() {} }
7637class BillingService { fun find() {} }
7638"#;
7639        let dir = tempdir().expect("temp dir");
7640        let root = dir.path();
7641        write_fixture(root, "src/Handler.kt", source);
7642        let mut cache = DispatchSourceCache::new();
7643
7644        let audit_ref = reference(
7645            "kotlin",
7646            "src/Handler.kt",
7647            "Handler::handle",
7648            "auditService",
7649            "find",
7650            line_of(source, "auditService.find()"),
7651        );
7652        assert_eq!(
7653            infer_receiver_type(root, &audit_ref, &mut cache).as_deref(),
7654            Some("AuditService")
7655        );
7656
7657        let user_ref = reference(
7658            "kotlin",
7659            "src/Handler.kt",
7660            "Handler::handle",
7661            "userService",
7662            "find",
7663            line_of(source, "userService.find()"),
7664        );
7665        assert_eq!(
7666            infer_receiver_type(root, &user_ref, &mut cache).as_deref(),
7667            Some("UserService")
7668        );
7669
7670        let billing_ref = reference(
7671            "kotlin",
7672            "src/Handler.kt",
7673            "Handler::handle",
7674            "billingService",
7675            "find",
7676            line_of(source, "billingService.find()"),
7677        );
7678        assert_eq!(
7679            infer_receiver_type(root, &billing_ref, &mut cache).as_deref(),
7680            Some("BillingService")
7681        );
7682    }
7683
7684    #[test]
7685    fn cpp_declarator_and_auto_factory_receiver_types_are_inferred() {
7686        let source = r#"struct Foo { void run(); };
7687struct PointerFoo { void run(); };
7688struct FactoryFoo { void run(); };
7689FactoryFoo makeFactoryFoo();
7690
7691void handle() {
7692    Foo foo;
7693    foo.run();
7694    PointerFoo* pointerFoo = nullptr;
7695    pointerFoo->run();
7696    auto factoryFoo = makeFactoryFoo();
7697    factoryFoo.run();
7698}
7699"#;
7700        let dir = tempdir().expect("temp dir");
7701        let root = dir.path();
7702        write_fixture(root, "src/fixture.cpp", source);
7703        let mut cache = DispatchSourceCache::new();
7704
7705        let foo_ref = reference(
7706            "cpp",
7707            "src/fixture.cpp",
7708            "handle",
7709            "foo",
7710            "run",
7711            line_of(source, "foo.run()"),
7712        );
7713        assert_eq!(
7714            infer_receiver_type(root, &foo_ref, &mut cache).as_deref(),
7715            Some("Foo")
7716        );
7717
7718        let pointer_ref = reference(
7719            "cpp",
7720            "src/fixture.cpp",
7721            "handle",
7722            "pointerFoo",
7723            "run",
7724            line_of(source, "pointerFoo->run()"),
7725        );
7726        assert_eq!(
7727            infer_receiver_type(root, &pointer_ref, &mut cache).as_deref(),
7728            Some("PointerFoo")
7729        );
7730
7731        let factory_ref = reference(
7732            "cpp",
7733            "src/fixture.cpp",
7734            "handle",
7735            "factoryFoo",
7736            "run",
7737            line_of(source, "factoryFoo.run()"),
7738        );
7739        assert_eq!(
7740            infer_receiver_type(root, &factory_ref, &mut cache).as_deref(),
7741            Some("FactoryFoo")
7742        );
7743    }
7744
7745    #[test]
7746    fn unknown_java_receiver_still_uses_name_match_fallback() {
7747        let source = r#"class EntryPoint {
7748    void handle() {
7749        service.runSpecial();
7750    }
7751}
7752
7753class OnlyService {
7754    void runSpecial() {}
7755}
7756"#;
7757        let dir = tempdir().expect("temp dir");
7758        let root = dir.path();
7759        write_fixture(root, "src/EntryPoint.java", source);
7760        let reference = reference(
7761            "java",
7762            "src/EntryPoint.java",
7763            "EntryPoint::handle",
7764            "service",
7765            "runSpecial",
7766            line_of(source, "service.runSpecial()"),
7767        );
7768        let mut cache = DispatchSourceCache::new();
7769
7770        assert!(infer_receiver_type(root, &reference, &mut cache).is_none());
7771        let candidates = vec![method_candidate("only", "OnlyService::runSpecial")];
7772        let selected = select_name_match_candidate(&reference, &candidates).expect("name match");
7773        assert_eq!(selected.scoped_name, "OnlyService::runSpecial");
7774    }
7775
7776    fn reference(
7777        lang: &str,
7778        caller_file: &str,
7779        caller_symbol: &str,
7780        receiver: &str,
7781        method_name: &str,
7782        line: u32,
7783    ) -> NameMatchRef {
7784        NameMatchRef {
7785            ref_id: format!("{caller_file}:{line}:{receiver}:{method_name}"),
7786            caller_node: format!("{caller_symbol}:node"),
7787            caller_file: caller_file.to_string(),
7788            caller_symbol: caller_symbol.to_string(),
7789            caller_signature: None,
7790            receiver: receiver.to_string(),
7791            method_name: method_name.to_string(),
7792            colon_dispatch: false,
7793            line,
7794            lang: lang.to_string(),
7795        }
7796    }
7797
7798    fn method_candidate(node_id: &str, scoped_name: &str) -> NameMatchCandidate {
7799        NameMatchCandidate {
7800            node_id: node_id.to_string(),
7801            file_path: "src/targets.fixture".to_string(),
7802            scoped_name: scoped_name.to_string(),
7803            kind: "method".to_string(),
7804        }
7805    }
7806
7807    fn write_fixture(root: &std::path::Path, rel_path: &str, source: &str) {
7808        let path = root.join(rel_path);
7809        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parent");
7810        fs::write(path, source).expect("write fixture");
7811    }
7812
7813    fn line_of(source: &str, needle: &str) -> u32 {
7814        source
7815            .lines()
7816            .position(|line| line.contains(needle))
7817            .map(|index| index as u32 + 1)
7818            .unwrap_or_else(|| panic!("missing line containing {needle:?}"))
7819    }
7820}