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