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