Skip to main content

lean_ctx/core/graph_index/
mod.rs

1// DEPRECATED: This module is being replaced by PropertyGraph (core/property_graph/).
2// New code should use GraphProvider (core/graph_provider.rs) instead of accessing
3// ProjectIndex directly. The dashboard now resolves graphs through
4// `graph_coordinator` (PropertyGraph-first); the remaining direct consumers are
5// the build pipeline (index_orchestrator) and the extractor itself.
6// See OPT-14/15 (#696) plan for the full migration path.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use rayon::prelude::*;
12use serde::{Deserialize, Serialize};
13
14use crate::core::import_resolver;
15use crate::core::signatures;
16mod edges;
17pub(crate) use edges::*;
18#[allow(unreachable_pub)]
19#[allow(dead_code)]
20pub(crate) mod file_id;
21#[cfg(test)]
22mod tests;
23
24const INDEX_VERSION: u32 = 6;
25
26// Path-key utilities moved to `core::index_paths` (#682); re-exported so existing
27// `graph_index::…` call sites keep compiling during the migration.
28use crate::core::index_paths::normalize_absolute_path;
29pub use crate::core::index_paths::{graph_match_key, graph_relative_key, normalize_project_root};
30
31pub fn is_safe_scan_root_public(path: &str) -> bool {
32    is_safe_scan_root(path)
33}
34
35fn is_filesystem_root(path: &str) -> bool {
36    let p = Path::new(path);
37    p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
38}
39
40/// Returns `true` if `dir` contains a known project marker.
41///
42/// Delegates to the single TCC-guarded probe in `pathutil` (#356) so a
43/// launchd-standalone process never stats marker files under ~/Documents, and
44/// the marker set stays defined in exactly one place (`pathutil::PROJECT_MARKERS`).
45fn dir_has_project_marker(dir: &Path) -> bool {
46    crate::core::pathutil::has_project_marker(dir)
47}
48
49/// True if `p` or any ancestor strictly *below* `stop` contains a project
50/// marker. Subdirectories of a real project (e.g. `repo/rust/src`) are
51/// legitimate scan roots even though the marker lives at the repo root —
52/// refusing them produced WARN noise on every grep/ls inside ~/Documents
53/// projects (GL#438). `stop` itself is never checked, so a marker-less
54/// `~/Documents` stays refused.
55fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
56    let mut cur = Some(p);
57    while let Some(dir) = cur {
58        if dir == stop {
59            return false;
60        }
61        if dir_has_project_marker(dir) {
62            return true;
63        }
64        cur = dir.parent();
65    }
66    false
67}
68
69fn is_safe_scan_root(path: &str) -> bool {
70    let normalized = normalize_project_root(path);
71    let p = Path::new(&normalized);
72
73    // macOS TCC (#356): a launchd-standalone process must never stat or
74    // enumerate under ~/Documents/Desktop/Downloads. Refuse such roots before
75    // any marker probe / read_dir runs. Editor- and CLI-attached processes
76    // inherit a TCC grant and keep indexing those projects normally.
77    if !crate::core::pathutil::may_probe_path(p) {
78        return false;
79    }
80
81    if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
82        tracing::warn!("[graph_index: refusing to scan filesystem root]");
83        return false;
84    }
85
86    if normalized == "." || normalized.is_empty() {
87        tracing::warn!("[graph_index: refusing to scan relative/empty root]");
88        return false;
89    }
90
91    if let Some(home) = dirs::home_dir() {
92        let home_norm = normalize_project_root(&home.to_string_lossy());
93        if normalized == home_norm {
94            use std::sync::Once;
95            static HOME_WARN: Once = Once::new();
96            HOME_WARN.call_once(|| {
97                tracing::warn!(
98                    "[graph_index: skipping — cannot index home directory {normalized}.\n  \
99                     Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
100                );
101            });
102            return false;
103        }
104        // macOS TCC: Documents/Desktop/Downloads pop a privacy prompt the moment
105        // we stat or enumerate inside them (#356). They are never valid scan roots,
106        // so refuse here before any has_marker stat or read_dir runs.
107        if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
108            tracing::warn!(
109                "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
110            );
111            return false;
112        }
113        // Block common broad home subdirectories that are never valid project roots
114        let home_path = Path::new(&home_norm);
115        const BLOCKED_HOME_SUBDIRS: &[&str] = &[
116            "Desktop",
117            "Documents",
118            "Downloads",
119            "Pictures",
120            "Music",
121            "Videos",
122            "Movies",
123            "Library",
124            ".local",
125            ".cache",
126            ".config",
127            "snap",
128            "Applications",
129            // Cloud-sync roots: scanning these forces on-demand providers to
130            // hydrate (download) every placeholder file/folder (#363). iCloud's
131            // backing dir (~/Library/Mobile Documents) is already covered by
132            // "Library" above.
133            "OneDrive",
134            "Dropbox",
135            "Google Drive",
136        ];
137        for blocked in BLOCKED_HOME_SUBDIRS {
138            let blocked_path = home_path.join(blocked);
139            let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
140            // Markers may live in an *ancestor*: `repo/rust/src` is a legitimate
141            // scan root of the project rooted at `repo` (GL#438). Walk up to (but
142            // not past) the blocked dir itself, so `~/Documents` without any
143            // project stays refused.
144            let has_marker = has_marker_in_ancestry(p, &blocked_path);
145            if is_inside_blocked
146                && !has_marker
147                && !crate::core::pathutil::has_multi_repo_children(p)
148            {
149                tracing::warn!(
150                    "[graph_index: refusing to scan {normalized} — \
151                     inside home/{blocked} without project markers]"
152                );
153                return false;
154            }
155        }
156
157        // Block directories that are direct children of home without project markers
158        // (but allow multi-repo workspace parents like ~/code/)
159        if p.parent() == Some(home_path)
160            && !dir_has_project_marker(p)
161            && !crate::core::pathutil::has_multi_repo_children(p)
162        {
163            tracing::warn!(
164                "[graph_index: refusing to scan {normalized} — \
165                 direct child of home without project markers]"
166            );
167            return false;
168        }
169    }
170
171    let breadth_markers = [
172        ".git",
173        "Cargo.toml",
174        "package.json",
175        "go.mod",
176        "pyproject.toml",
177        "setup.py",
178        "Makefile",
179        "CMakeLists.txt",
180        "pnpm-workspace.yaml",
181        ".projectile",
182        "BUILD.bazel",
183        "go.work",
184    ];
185
186    if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
187        // Multi-repo workspace parent: >=2 children with project markers is always safe
188        if crate::core::pathutil::has_multi_repo_children(p) {
189            return true;
190        }
191
192        let child_count = std::fs::read_dir(p).map_or(0, |rd| {
193            rd.filter_map(Result::ok)
194                .filter(|e| e.path().is_dir())
195                .count()
196        });
197        if child_count > 50 {
198            tracing::warn!(
199                "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
200                 skipping scan to avoid indexing broad directories]"
201            );
202            return false;
203        }
204    }
205
206    true
207}
208
209/// True if the directory contains a .NET project/solution file (`*.csproj`,
210/// `*.sln`, `*.fsproj`, `*.vbproj`). Filenames vary, so we match by extension —
211/// these are strong project-root markers even when there is no `.git`.
212fn dir_has_dotnet_project(dir: &Path) -> bool {
213    std::fs::read_dir(dir).is_ok_and(|rd| {
214        rd.filter_map(Result::ok).any(|e| {
215            e.path()
216                .extension()
217                .and_then(|x| x.to_str())
218                .is_some_and(|x| {
219                    matches!(
220                        x.to_ascii_lowercase().as_str(),
221                        "csproj" | "sln" | "fsproj" | "vbproj"
222                    )
223                })
224        })
225    })
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ProjectIndex {
230    pub version: u32,
231    pub project_root: String,
232    pub last_scan: String,
233    pub files: HashMap<String, FileEntry>,
234    pub edges: Vec<IndexEdge>,
235    pub symbols: HashMap<String, SymbolEntry>,
236    /// Graph-local path interner: one owned allocation per distinct file path.
237    /// Populated during scan; used for O(1) path→id lookups in queries.
238    /// Skipped during serde — rebuilt from `files` keys on deserialization.
239    #[serde(skip)]
240    pub(crate) interner: file_id::PathInterner,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct FileEntry {
245    pub path: String,
246    pub hash: String,
247    pub language: String,
248    pub line_count: usize,
249    pub token_count: usize,
250    pub exports: Vec<String>,
251    pub summary: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
255pub struct SymbolEntry {
256    pub file: String,
257    pub name: String,
258    pub kind: String,
259    pub start_line: usize,
260    pub end_line: usize,
261    pub is_exported: bool,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265pub struct IndexEdge {
266    pub from: String,
267    pub to: String,
268    pub kind: String,
269    #[serde(default = "default_edge_weight")]
270    pub weight: f32,
271}
272
273fn default_edge_weight() -> f32 {
274    1.0
275}
276
277impl ProjectIndex {
278    pub fn new(project_root: &str) -> Self {
279        Self {
280            version: INDEX_VERSION,
281            project_root: normalize_project_root(project_root),
282            last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
283            files: HashMap::new(),
284            edges: Vec::new(),
285            symbols: HashMap::new(),
286            interner: file_id::PathInterner::new(),
287        }
288    }
289
290    /// Rebuild the interner from the current `files` keys. Called after
291    /// deserialization or materialization from the property graph, where
292    /// `interner` is skipped/absent.
293    pub(crate) fn rebuild_interner(&mut self) {
294        let mut interner = file_id::PathInterner::with_capacity(self.files.len());
295        for key in self.files.keys() {
296            interner.intern(key);
297        }
298        self.interner = interner;
299    }
300
301    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
302        let normalized = normalize_project_root(project_root);
303        let hash = crate::core::project_hash::hash_project_root(&normalized);
304        crate::core::data_dir::lean_ctx_data_dir()
305            .ok()
306            .map(|d| d.join("graphs").join(hash))
307    }
308
309    /// Reconstruct the index from the property graph — the sole persistence
310    /// store since #696 C4. The PG is a parity-proven lossless superset of the
311    /// former JSON index, so this is a faithful round-trip (see
312    /// `graph_provider::materialize_project_index` + its round-trip test).
313    /// `None` when the graph has not been built yet (empty file catalog).
314    pub fn load(project_root: &str) -> Option<Self> {
315        let graph = crate::core::property_graph::CodeGraph::open(project_root).ok()?;
316        if graph.file_catalog_count().unwrap_or(0) == 0 {
317            return None;
318        }
319        let provider = crate::core::graph_provider::GraphProvider::PropertyGraph(graph);
320        let mut index = provider.materialize_project_index(project_root);
321        index.rebuild_interner();
322        Some(index)
323    }
324
325    /// Persist the index by mirroring it into the property graph (the sole store
326    /// since #696 C4). Replaces the former `index.json.zst` write; the mirror
327    /// also stamps `graph.meta.json`, which the resident graph cache fingerprints
328    /// for invalidation.
329    pub fn save(&self) -> Result<(), String> {
330        crate::core::property_graph::mirror_index(&self.project_root, self)
331            .map_err(|e| e.to_string())
332    }
333
334    /// Remove all cached graph indices that are older than max_age_hours.
335    /// Called on startup/update to prevent stale data from persisting.
336    pub fn purge_stale_indices() {
337        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
338            return;
339        };
340        let graphs_dir = data_dir.join("graphs");
341        let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
342            return;
343        };
344        let cfg = crate::core::config::Config::load();
345        let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
346
347        for entry in entries.filter_map(Result::ok) {
348            let path = entry.path();
349            if !path.is_dir() {
350                continue;
351            }
352            // #696 C4: the property graph (graph.db, stamped by graph.meta.json)
353            // is the sole store; age the directory by its last build instead of
354            // the retired JSON index.
355            let meta = path.join("graph.meta.json");
356            let db = path.join("graph.db");
357            let index_file = if meta.exists() {
358                &meta
359            } else if db.exists() {
360                &db
361            } else {
362                continue;
363            };
364
365            let is_old = index_file
366                .metadata()
367                .and_then(|m| m.modified())
368                .is_ok_and(|mtime| {
369                    mtime
370                        .elapsed()
371                        .is_ok_and(|age| age.as_secs() > max_age_secs)
372                });
373
374            if is_old {
375                tracing::info!("[graph_index: purging stale index at {}]", path.display());
376                let _ = std::fs::remove_dir_all(&path);
377            }
378        }
379    }
380
381    pub fn file_count(&self) -> usize {
382        self.files.len()
383    }
384
385    pub fn symbol_count(&self) -> usize {
386        self.symbols.len()
387    }
388
389    pub fn edge_count(&self) -> usize {
390        self.edges.len()
391    }
392
393    pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
394        self.symbols.get(key)
395    }
396
397    pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
398        let mut result = Vec::new();
399        let mut visited = std::collections::HashSet::new();
400        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
401
402        while let Some((current, d)) = queue.pop() {
403            if d > depth || visited.contains(&current) {
404                continue;
405            }
406            visited.insert(current.clone());
407            if current != path {
408                result.push(current.clone());
409            }
410
411            for edge in &self.edges {
412                if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
413                    queue.push((edge.from.clone(), d + 1));
414                }
415            }
416        }
417        result
418    }
419
420    /// Forward import dependencies: files that `path` (transitively) imports.
421    /// Mirror of `get_reverse_deps` with the edge direction flipped.
422    pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
423        let mut result = Vec::new();
424        let mut visited = std::collections::HashSet::new();
425        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
426
427        while let Some((current, d)) = queue.pop() {
428            if d > depth || visited.contains(&current) {
429                continue;
430            }
431            visited.insert(current.clone());
432            if current != path {
433                result.push(current.clone());
434            }
435
436            for edge in &self.edges {
437                if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
438                    queue.push((edge.to.clone(), d + 1));
439                }
440            }
441        }
442        result
443    }
444
445    pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
446        let mut result = Vec::new();
447        let mut visited = std::collections::HashSet::new();
448        let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
449
450        while let Some((current, d)) = queue.pop() {
451            if d > depth || visited.contains(&current) {
452                continue;
453            }
454            visited.insert(current.clone());
455            if current != path {
456                result.push(current.clone());
457            }
458
459            for edge in &self.edges {
460                if edge.from == current && !visited.contains(&edge.to) {
461                    queue.push((edge.to.clone(), d + 1));
462                }
463                if edge.to == current && !visited.contains(&edge.from) {
464                    queue.push((edge.from.clone(), d + 1));
465                }
466            }
467        }
468        result
469    }
470}
471
472/// Load the best available graph index, trying multiple root path variants.
473/// If no valid index exists, automatically scans the project to build one.
474/// This is the primary entry point — ensures zero-config usage.
475pub fn load_or_build(project_root: &str) -> ProjectIndex {
476    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
477        return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
478    }
479
480    // Prefer stable absolute roots. Using "." as a cache key is fragile because
481    // it depends on the process cwd and can accidentally load the wrong project.
482    let root_abs = if project_root.trim().is_empty() || project_root == "." {
483        std::env::current_dir().ok().map_or_else(
484            || ".".to_string(),
485            |p| normalize_project_root(&p.to_string_lossy()),
486        )
487    } else {
488        normalize_project_root(project_root)
489    };
490
491    if !is_safe_scan_root(&root_abs) {
492        return ProjectIndex::new(&root_abs);
493    }
494
495    // Try the absolute/root-normalized path first.
496    if let Some(idx) = ProjectIndex::load(&root_abs)
497        && !idx.files.is_empty()
498    {
499        if index_looks_stale(&idx, &root_abs) {
500            tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
501            return scan(&root_abs);
502        }
503        return idx;
504    }
505
506    // CWD fallback: only use if CWD is a subdirectory of root_abs (same project)
507    if let Ok(cwd) = std::env::current_dir() {
508        let cwd_str = normalize_project_root(&cwd.to_string_lossy());
509        if cwd_str != root_abs
510            && cwd_str.starts_with(&root_abs)
511            && let Some(idx) = ProjectIndex::load(&cwd_str)
512            && !idx.files.is_empty()
513        {
514            if index_looks_stale(&idx, &cwd_str) {
515                return scan(&cwd_str);
516            }
517            return idx;
518        }
519    }
520
521    scan(&root_abs)
522}
523
524fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
525    if index.files.is_empty() {
526        return true;
527    }
528
529    // TTL check: rebuild if index is older than configured max_age_hours
530    if let Ok(scan_time) =
531        chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
532    {
533        let cfg = crate::core::config::Config::load();
534        let effective_hours = cfg.archive_max_age_hours_effective();
535        let max_age = chrono::Duration::hours(effective_hours as i64);
536        let now = chrono::Local::now().naive_local();
537        if now.signed_duration_since(scan_time) > max_age {
538            tracing::info!(
539                "[graph_index: index is older than {}h — marking stale]",
540                effective_hours
541            );
542            return true;
543        }
544    }
545
546    // Contamination check: if index contains paths from common user directories,
547    // it was built from a too-broad root and must be rebuilt
548    const CONTAMINATION_MARKERS: &[&str] = &[
549        "Desktop/",
550        "Documents/",
551        "Downloads/",
552        "Pictures/",
553        "Music/",
554        "Videos/",
555        "Movies/",
556        "Library/",
557        ".cache/",
558        "snap/",
559    ];
560    let contaminated = index.files.keys().take(200).any(|rel| {
561        CONTAMINATION_MARKERS
562            .iter()
563            .any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
564    });
565    if contaminated {
566        tracing::warn!(
567            "[graph_index: index contains files from user directories (Desktop/Documents/...) — \
568             marking stale to force clean rebuild]"
569        );
570        return true;
571    }
572
573    let root_path = Path::new(root_abs);
574    // Sample up to 20 files for existence check (avoid scanning all files in large indices)
575    let sample_size = index.files.len().min(20);
576    for rel in index.files.keys().take(sample_size) {
577        let rel = rel.trim_start_matches(['/', '\\']);
578        if rel.is_empty() {
579            continue;
580        }
581        let abs = root_path.join(rel);
582        if !abs.exists() {
583            return true;
584        }
585    }
586
587    // Content-aware staleness: rescan only when source *content* actually
588    // changed. mtime is a cheap prefilter; the change is then confirmed against
589    // the stored content hash so a `touch`/checkout/format that leaves bytes
590    // unchanged never forces a needless rescan (covers edits and new files).
591    if source_content_changed_since_index(index, root_abs) {
592        tracing::info!("[graph_index: source content changed since last scan — marking stale]");
593        return true;
594    }
595
596    false
597}
598
599/// Modified time of the persisted index artifact, if one exists.
600///
601/// Since #696 C4 the property graph is the sole store, so staleness is measured
602/// against `graph.meta.json` (rewritten on every mirror) with the `graph.db`
603/// file as a fallback.
604fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
605    let dir = ProjectIndex::index_dir(root_abs)?;
606    for name in ["graph.meta.json", "graph.db"] {
607        if let Ok(meta) = std::fs::metadata(dir.join(name))
608            && let Ok(modified) = meta.modified()
609        {
610            return Some(modified);
611        }
612    }
613    None
614}
615
616/// Bounded staleness check that confirms *content* changes, not just mtimes.
617///
618/// An mtime newer than the persisted index only flags a *candidate*; the change
619/// is then confirmed by comparing the file's content hash against the stored
620/// `FileEntry.hash` (same `compute_hash` + `read_to_string` the scan uses, so
621/// the comparison is exact). This means a `touch`, `git checkout`, or formatter
622/// rewrite that leaves bytes unchanged no longer forces a needless rescan, while
623/// genuine edits and newly added files still mark the index stale.
624///
625/// Both the traversal and the number of confirming reads are capped: exceeding
626/// the read cap returns `true` (conservatively stale) instead of reading an
627/// unbounded amount. Removed files are handled by the earlier existence check.
628fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
629    let Some(index_mtime) = index_file_mtime(root_abs) else {
630        // No persisted index yet — the existence/TTL checks above already decided.
631        return false;
632    };
633    // #735: excluded files must not flag the index stale (a change to CSV seed
634    // data would otherwise force needless graph rescans forever).
635    let index_filter = crate::core::index_filter::IndexFileFilter::effective();
636    let walker = ignore::WalkBuilder::new(root_abs)
637        .hidden(true)
638        .git_ignore(index_filter.respect_gitignore)
639        .git_global(index_filter.respect_gitignore)
640        .git_exclude(index_filter.respect_gitignore)
641        .require_git(false)
642        .max_depth(Some(20))
643        .filter_entry(crate::core::walk_filter::keep_entry)
644        .build();
645    const MAX_VISIT: usize = 50_000;
646    const MAX_CONFIRM_READS: usize = 4_000;
647    let mut visited = 0usize;
648    let mut confirm_reads = 0usize;
649    for entry in walker.filter_map(std::result::Result::ok) {
650        visited += 1;
651        if visited > MAX_VISIT {
652            break;
653        }
654        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
655            continue;
656        }
657        let path = entry.path();
658        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
659        if !is_indexable_ext(ext) {
660            continue;
661        }
662        // mtime prefilter: only files touched after the index are candidates.
663        let Ok(meta) = entry.metadata() else { continue };
664        let Ok(modified) = meta.modified() else {
665            continue;
666        };
667        if modified <= index_mtime {
668            continue;
669        }
670        // Candidate: confirm against the stored content hash.
671        let rel = make_relative(&path.to_string_lossy(), root_abs);
672        if index_filter.is_excluded(&rel.replace('\\', "/")) {
673            continue;
674        }
675        let Some(file_entry) = index.files.get(&rel) else {
676            // A newly added indexable file is genuinely new content.
677            return true;
678        };
679        confirm_reads += 1;
680        if confirm_reads > MAX_CONFIRM_READS {
681            // Too many candidates to verify cheaply — assume stale.
682            return true;
683        }
684        match std::fs::read_to_string(path) {
685            // Bytes unchanged despite a newer mtime → not a real change.
686            Ok(content) if compute_hash(&content) == file_entry.hash => {}
687            // Edited content, or no longer readable as it was at scan time.
688            _ => return true,
689        }
690    }
691    false
692}
693
694/// Delete the persisted graph-index artifacts for a project so the next scan
695/// rebuilds from scratch. Backs `graph build --force`.
696///
697/// Since #696 C4 the property graph (`graph.db` + `graph.meta.json`) is the sole
698/// store; the legacy JSON names are still removed so upgrades clear stale files.
699pub fn purge_index(project_root: &str) {
700    if let Some(dir) = ProjectIndex::index_dir(project_root) {
701        for name in [
702            "graph.db",
703            "graph.db-wal",
704            "graph.db-shm",
705            "graph.meta.json",
706            "index.json.zst",
707            "index.json",
708            "call_graph.json.zst",
709        ] {
710            let _ = std::fs::remove_file(dir.join(name));
711        }
712    }
713}
714
715pub fn scan(project_root: &str) -> ProjectIndex {
716    scan_inner(project_root).0
717}
718
719pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
720    scan_inner(project_root)
721}
722
723fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
724    if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
725        tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
726        return (ProjectIndex::new(project_root), HashMap::new());
727    }
728
729    let project_root = normalize_project_root(project_root);
730
731    if !is_safe_scan_root(&project_root) {
732        tracing::debug!("[graph_index: scan aborted for unsafe root {project_root}]");
733        return (ProjectIndex::new(&project_root), HashMap::new());
734    }
735
736    let lock_name = format!(
737        "graph-idx-{}",
738        &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
739    );
740    let _lock = crate::core::startup_guard::try_acquire_lock(
741        &lock_name,
742        std::time::Duration::from_millis(800),
743        std::time::Duration::from_mins(3),
744    );
745    if _lock.is_none() {
746        tracing::info!(
747            "[graph_index: another process is scanning {project_root} — returning cached or empty]"
748        );
749        return (
750            ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
751            HashMap::new(),
752        );
753    }
754
755    let existing = ProjectIndex::load(&project_root);
756    let mut index = ProjectIndex::new(&project_root);
757
758    // Borrow prior symbols instead of cloning every hash, key, and SymbolEntry up
759    // front. Reused symbols are cloned only for the current scan batch as they
760    // move into the replacement index, avoiding a third full-index copy.
761    let previous_symbols = existing
762        .as_ref()
763        .map(previous_symbols_by_file)
764        .unwrap_or_default();
765
766    let cfg = crate::core::config::Config::load();
767    // #735: shared corpus filter — same membership decision as the BM25 walk.
768    let index_filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
769
770    let walker = ignore::WalkBuilder::new(&project_root)
771        .hidden(true)
772        .git_ignore(index_filter.respect_gitignore)
773        .git_global(index_filter.respect_gitignore)
774        .git_exclude(index_filter.respect_gitignore)
775        .require_git(false)
776        .max_depth(Some(20))
777        .filter_entry(crate::core::walk_filter::keep_entry)
778        .build();
779
780    let extra_ignores: Vec<glob::Pattern> = cfg
781        .extra_ignore_patterns
782        .iter()
783        .filter_map(|p| glob::Pattern::new(p).ok())
784        .collect();
785
786    let mut scanned = 0usize;
787    let mut reused = 0usize;
788    let mut entries_visited = 0usize;
789    let mut content_cache: HashMap<String, String> = HashMap::new();
790    let mut content_cache_bytes: usize = 0;
791    const CONTENT_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024; // #790: cap at 256MB
792    let max_files = if cfg.graph_index_max_files == 0 {
793        usize::MAX // unlimited
794    } else {
795        cfg.graph_index_max_files as usize
796    };
797    const MAX_ENTRIES_VISITED: usize = 500_000;
798    const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; // 2 MB per file
799    /// Maximum phase-2 fan-out. Actual batches shrink with guardian headroom.
800    const SCAN_BATCH_FILES: usize = 500;
801    const SCAN_MIN_BATCH_FILES: usize = 1;
802    // Per-file scan: content string (~20 KB avg) + SHA-256 state +
803    // signature extraction + line/token counts. Lighter than BM25
804    // because no chunk splitting or lowered-token vectors are built.
805    const SCAN_EST_TRANSIENT_PER_FILE: u64 = 192 * 1024;
806    let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
807
808    // #934: two-phase scan. Phase 1 walks the tree sequentially (cheap; it
809    // carries the traversal caps / timeout / memory early-breaks) and collects
810    // the files to index. Phase 2 fans the expensive per-file signature
811    // extraction across a rayon pool — pure and thread-safe (the tree-sitter
812    // parser is `thread_local!`; `previous_symbols`/`existing` are read-only). Phase 3
813    // merges sequentially: `files`/`symbols` are keyed per file, so the result is
814    // identical to a sequential scan (edges are built and sorted afterwards).
815    let mut targets: Vec<(String, String, String)> = Vec::new();
816    for entry in walker.filter_map(std::result::Result::ok) {
817        entries_visited += 1;
818        if entries_visited > MAX_ENTRIES_VISITED {
819            tracing::warn!(
820                "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
821                 runaway traversal. Indexed {} files so far.]",
822                targets.len()
823            );
824            break;
825        }
826        if entries_visited.is_multiple_of(5000) {
827            if std::time::Instant::now() > scan_deadline {
828                tracing::warn!(
829                    "[graph_index: scan timeout (120s) after {entries_visited} entries — \
830                     saving partial index with {} files]",
831                    targets.len()
832                );
833                break;
834            }
835            if crate::core::memory_guard::abort_requested() {
836                tracing::warn!(
837                    "[graph_index: memory pressure abort after {entries_visited} entries — \
838                     saving partial index with {} files]",
839                    targets.len()
840                );
841                break;
842            }
843            if crate::core::memory_guard::is_under_pressure() {
844                tracing::warn!(
845                    "[graph_index: memory pressure detected at {entries_visited} entries — \
846                     stopping scan with {} files]",
847                    targets.len()
848                );
849                break;
850            }
851            if let Some(ref g) = _lock {
852                g.touch();
853            }
854        }
855
856        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
857            continue;
858        }
859
860        if entry.path_is_symlink() {
861            continue;
862        }
863        let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
864
865        if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
866            continue;
867        }
868
869        if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
870            if meta.file_type().is_symlink() || !meta.is_file() {
871                continue;
872            }
873            if meta.len() > MAX_FILE_SIZE_BYTES {
874                tracing::debug!(
875                    "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
876                    meta.len() as f64 / 1_048_576.0,
877                    MAX_FILE_SIZE_BYTES / (1024 * 1024),
878                );
879                continue;
880            }
881        }
882
883        let ext = Path::new(&file_path)
884            .extension()
885            .and_then(|e| e.to_str())
886            .unwrap_or("");
887
888        if !is_indexable_ext(ext) {
889            continue;
890        }
891
892        let rel = make_relative(&file_path, &project_root);
893        if extra_ignores.iter().any(|p| p.matches(&rel)) {
894            continue;
895        }
896        if index_filter.is_excluded(&rel.replace('\\', "/")) {
897            continue;
898        }
899
900        if max_files != usize::MAX && targets.len() >= max_files {
901            tracing::info!(
902                "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
903                max_files
904            );
905            break;
906        }
907
908        // Own `ext` before moving `file_path` (it borrows from it).
909        let ext = ext.to_string();
910        targets.push((file_path, rel, ext));
911    }
912
913    // #790: admission control — check if parallel fan-out fits memory headroom.
914    // Degrades to sequential (with per-file pressure breaks) on huge corpora,
915    // mirroring BM25's admission gate.
916    let target_rels: Vec<String> = targets.iter().map(|(_, r, _)| r.clone()).collect();
917    let admission = crate::core::index_admission::admit_files(
918        crate::core::index_admission::BuildKind::GraphScan,
919        std::path::Path::new(&project_root),
920        &target_rels,
921    );
922    let parallel = admission.parallel_ok && !crate::core::memory_guard::is_under_pressure();
923    let mut files_done = 0;
924    while files_done < targets.len() {
925        if crate::core::memory_guard::abort_requested() {
926            tracing::warn!(
927                "[graph_index: aborting scan after {files_done} files due to critical memory pressure]"
928            );
929            break;
930        }
931        if crate::core::memory_guard::is_under_pressure() {
932            tracing::warn!(
933                "[graph_index: stopping scan after {files_done} files due to memory pressure]"
934            );
935            break;
936        }
937        let batch_size = crate::core::memory_guard::adaptive_batch_size(
938            SCAN_MIN_BATCH_FILES,
939            SCAN_BATCH_FILES,
940            SCAN_EST_TRANSIENT_PER_FILE,
941        );
942        let batch_end = (files_done + batch_size).min(targets.len());
943        let results = process_scan_targets(
944            &targets[files_done..batch_end],
945            &previous_symbols,
946            existing.as_ref(),
947            parallel,
948        );
949        for r in results {
950            if r.reused {
951                reused += 1;
952            } else {
953                scanned += 1;
954            }
955            index.files.insert(r.rel.clone(), r.file_entry);
956            for (key, sym) in r.symbols {
957                index.symbols.insert(key, sym);
958            }
959            if content_cache_bytes < CONTENT_CACHE_MAX_BYTES {
960                content_cache_bytes += r.content.len();
961                content_cache.insert(r.rel, r.content);
962            }
963        }
964        files_done = batch_end;
965        crate::core::memory_guard::jemalloc_purge();
966    }
967
968    index.rebuild_interner();
969    build_edges_cached(&mut index, &content_cache);
970
971    if let Err(e) = index.save() {
972        tracing::warn!("could not save graph index: {e}");
973    }
974
975    tracing::debug!(
976        "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
977        index.file_count(),
978        scanned,
979        reused,
980        index.symbol_count(),
981        index.edge_count()
982    );
983
984    (index, content_cache)
985}
986
987/// Borrowed previous-scan symbols grouped by file. This index stores only
988/// references, so incremental scans do not clone the complete prior index before
989/// processing starts.
990type PreviousSymbols<'a> = HashMap<&'a str, Vec<(&'a str, &'a SymbolEntry)>>;
991
992fn previous_symbols_by_file(index: &ProjectIndex) -> PreviousSymbols<'_> {
993    let mut by_file: PreviousSymbols<'_> = HashMap::new();
994    for (key, symbol) in &index.symbols {
995        by_file
996            .entry(symbol.file.as_str())
997            .or_default()
998            .push((key.as_str(), symbol));
999    }
1000    by_file
1001}
1002
1003/// One file's contribution to the scan, produced off-thread by
1004/// [`process_scan_file`] and merged sequentially by [`scan_inner`].
1005#[derive(Debug, PartialEq)]
1006struct ScanFileResult {
1007    rel: String,
1008    file_entry: FileEntry,
1009    /// `(key, symbol)` pairs in signature order; `key` is `"<rel>::<name>"`.
1010    symbols: Vec<(String, SymbolEntry)>,
1011    content: String,
1012    reused: bool,
1013}
1014
1015/// Pure, thread-safe per-file scan work: read, hash, reuse-check against the
1016/// previous index, then extract signatures + file metadata. Returns `None` when
1017/// the file cannot be read (the sequential scan would `continue` past it).
1018fn process_scan_file(
1019    file_path: &str,
1020    rel: &str,
1021    ext: &str,
1022    previous_symbols: &PreviousSymbols<'_>,
1023    existing: Option<&ProjectIndex>,
1024) -> Option<ScanFileResult> {
1025    if crate::core::memory_guard::abort_requested() {
1026        return None;
1027    }
1028    let content = std::fs::read_to_string(file_path).ok()?;
1029    let hash = compute_hash(&content);
1030
1031    // Unchanged file with a prior entry: reuse verbatim (no re-parse). Clone
1032    // only this file's symbols into the replacement index; the complete prior
1033    // symbol table remains borrowed throughout the scan.
1034    if let Some(old_entry) = existing.and_then(|p| p.files.get(rel))
1035        && old_entry.hash == hash
1036    {
1037        let symbols = previous_symbols
1038            .get(rel)
1039            .into_iter()
1040            .flatten()
1041            .map(|(key, symbol)| ((*key).to_string(), (*symbol).clone()))
1042            .collect();
1043        return Some(ScanFileResult {
1044            rel: rel.to_string(),
1045            file_entry: old_entry.clone(),
1046            symbols,
1047            content,
1048            reused: true,
1049        });
1050    }
1051
1052    if crate::core::memory_guard::abort_requested() {
1053        return None;
1054    }
1055
1056    let sigs = signatures::extract_signatures(&content, ext);
1057    let line_count = content.lines().count();
1058    let token_count = crate::core::tokens::count_tokens(&content);
1059    let summary = extract_summary(&content);
1060
1061    let exports: Vec<String> = sigs
1062        .iter()
1063        .filter(|s| s.is_exported)
1064        .map(|s| s.name.clone())
1065        .collect();
1066
1067    let file_entry = FileEntry {
1068        path: rel.to_string(),
1069        hash,
1070        language: ext.to_string(),
1071        line_count,
1072        token_count,
1073        exports,
1074        summary,
1075    };
1076
1077    let symbols: Vec<(String, SymbolEntry)> = sigs
1078        .iter()
1079        .map(|sig| {
1080            let (start, end) = sig
1081                .start_line
1082                .zip(sig.end_line)
1083                .unwrap_or_else(|| find_symbol_range(&content, sig));
1084            let key = format!("{rel}::{}", sig.name);
1085            (
1086                key,
1087                SymbolEntry {
1088                    file: rel.to_string(),
1089                    name: sig.name.clone(),
1090                    kind: sig.kind.to_string(),
1091                    start_line: start,
1092                    end_line: end,
1093                    is_exported: sig.is_exported,
1094                },
1095            )
1096        })
1097        .collect();
1098
1099    Some(ScanFileResult {
1100        rel: rel.to_string(),
1101        file_entry,
1102        symbols,
1103        content,
1104        reused: false,
1105    })
1106}
1107
1108/// Run [`process_scan_file`] over every target. `par_iter().collect()` preserves
1109/// input order, so the [`scan_inner`] merge is order-stable whether or not the
1110/// pool is used; the sequential branch is the memory-pressure fallback and an
1111/// equivalence anchor for the determinism tests.
1112fn process_scan_targets(
1113    targets: &[(String, String, String)],
1114    previous_symbols: &PreviousSymbols<'_>,
1115    existing: Option<&ProjectIndex>,
1116    parallel: bool,
1117) -> Vec<ScanFileResult> {
1118    if parallel {
1119        targets
1120            .par_iter()
1121            .filter_map(|(file_path, rel, ext)| {
1122                process_scan_file(file_path, rel, ext, previous_symbols, existing)
1123            })
1124            .collect()
1125    } else {
1126        targets
1127            .iter()
1128            .filter_map(|(file_path, rel, ext)| {
1129                process_scan_file(file_path, rel, ext, previous_symbols, existing)
1130            })
1131            .collect()
1132    }
1133}
1134
1135fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
1136    let lines: Vec<&str> = content.lines().collect();
1137    let mut start = 0;
1138
1139    for (i, line) in lines.iter().enumerate() {
1140        if line.contains(&sig.name) {
1141            let trimmed = line.trim();
1142            let is_def = trimmed.starts_with("fn ")
1143                || trimmed.starts_with("pub fn ")
1144                || trimmed.starts_with("pub(crate) fn ")
1145                || trimmed.starts_with("async fn ")
1146                || trimmed.starts_with("pub async fn ")
1147                || trimmed.starts_with("struct ")
1148                || trimmed.starts_with("pub struct ")
1149                || trimmed.starts_with("enum ")
1150                || trimmed.starts_with("pub enum ")
1151                || trimmed.starts_with("trait ")
1152                || trimmed.starts_with("pub trait ")
1153                || trimmed.starts_with("impl ")
1154                || trimmed.starts_with("class ")
1155                || trimmed.starts_with("export class ")
1156                || trimmed.starts_with("export function ")
1157                || trimmed.starts_with("export async function ")
1158                || trimmed.starts_with("function ")
1159                || trimmed.starts_with("async function ")
1160                || trimmed.starts_with("def ")
1161                || trimmed.starts_with("async def ")
1162                || trimmed.starts_with("func ")
1163                || trimmed.starts_with("interface ")
1164                || trimmed.starts_with("export interface ")
1165                || trimmed.starts_with("type ")
1166                || trimmed.starts_with("export type ")
1167                || trimmed.starts_with("const ")
1168                || trimmed.starts_with("export const ")
1169                || trimmed.starts_with("fun ")
1170                || trimmed.starts_with("private fun ")
1171                || trimmed.starts_with("public fun ")
1172                || trimmed.starts_with("internal fun ")
1173                || trimmed.starts_with("class ")
1174                || trimmed.starts_with("data class ")
1175                || trimmed.starts_with("sealed class ")
1176                || trimmed.starts_with("sealed interface ")
1177                || trimmed.starts_with("enum class ")
1178                || trimmed.starts_with("object ")
1179                || trimmed.starts_with("private object ")
1180                || trimmed.starts_with("interface ")
1181                || trimmed.starts_with("typealias ")
1182                || trimmed.starts_with("private typealias ");
1183            if is_def {
1184                start = i + 1;
1185                break;
1186            }
1187        }
1188    }
1189
1190    if start == 0 {
1191        return (1, lines.len().min(20));
1192    }
1193
1194    let base_indent = lines
1195        .get(start - 1)
1196        .map_or(0, |l| l.len() - l.trim_start().len());
1197
1198    let mut end = start;
1199    let mut brace_depth: i32 = 0;
1200    let mut found_open = false;
1201
1202    for (i, line) in lines.iter().enumerate().skip(start - 1) {
1203        for ch in line.chars() {
1204            if ch == '{' {
1205                brace_depth += 1;
1206                found_open = true;
1207            } else if ch == '}' {
1208                brace_depth -= 1;
1209            }
1210        }
1211
1212        end = i + 1;
1213
1214        if found_open && brace_depth <= 0 {
1215            break;
1216        }
1217
1218        if !found_open && i > start {
1219            let indent = line.len() - line.trim_start().len();
1220            if indent <= base_indent && !line.trim().is_empty() && i > start {
1221                end = i;
1222                break;
1223            }
1224        }
1225
1226        if end - start > 200 {
1227            break;
1228        }
1229    }
1230
1231    (start, end)
1232}
1233
1234fn extract_summary(content: &str) -> String {
1235    for line in content.lines().take(20) {
1236        let trimmed = line.trim();
1237        if trimmed.is_empty()
1238            || trimmed.starts_with("//")
1239            || trimmed.starts_with('#')
1240            || trimmed.starts_with("/*")
1241            || trimmed.starts_with('*')
1242            || trimmed.starts_with("use ")
1243            || trimmed.starts_with("import ")
1244            || trimmed.starts_with("from ")
1245            || trimmed.starts_with("require(")
1246            || trimmed.starts_with("package ")
1247        {
1248            continue;
1249        }
1250        return trimmed.chars().take(120).collect();
1251    }
1252    String::new()
1253}
1254
1255fn compute_hash(content: &str) -> String {
1256    use std::collections::hash_map::DefaultHasher;
1257    use std::hash::{Hash, Hasher};
1258
1259    let mut hasher = DefaultHasher::new();
1260    content.hash(&mut hasher);
1261    format!("{:016x}", hasher.finish())
1262}
1263
1264#[cfg(test)]
1265fn short_hash(input: &str) -> String {
1266    use std::collections::hash_map::DefaultHasher;
1267    use std::hash::{Hash, Hasher};
1268
1269    let mut hasher = DefaultHasher::new();
1270    input.hash(&mut hasher);
1271    format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1272}
1273
1274fn make_relative(path: &str, root: &str) -> String {
1275    graph_relative_key(path, root)
1276}
1277
1278fn is_indexable_ext(ext: &str) -> bool {
1279    crate::core::language_capabilities::is_indexable_ext(ext)
1280}
1281
1282#[cfg(test)]
1283fn kotlin_package_name(content: &str) -> Option<String> {
1284    content.lines().map(str::trim).find_map(|line| {
1285        line.strip_prefix("package ")
1286            .map(|rest| rest.trim().trim_end_matches(';').to_string())
1287    })
1288}