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