Skip to main content

sem_core/parser/
graph.rs

1//! Entity dependency graph — cross-file reference extraction.
2//!
3//! Implements a two-pass approach inspired by arXiv:2601.08773 (Reliable Graph-RAG):
4//! Pass 1: Extract all entities, build a symbol table (name → entity ID).
5//! Pass 2: For each entity, extract identifier references from its AST subtree,
6//!         resolve them against the symbol table to create edges.
7//!
8//! This enables impact analysis: "if I change entity X, what else is affected?"
9
10use std::collections::{HashMap, HashSet};
11use std::io::BufRead;
12use std::path::Path;
13use std::sync::{Arc, LazyLock, Mutex, OnceLock};
14
15#[cfg(feature = "parallel")]
16use rayon::prelude::*;
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19
20/// Helper macro to select parallel or sequential iteration based on feature flag.
21macro_rules! maybe_par_iter {
22    ($slice:expr) => {{
23        #[cfg(feature = "parallel")]
24        {
25            $slice.par_iter()
26        }
27        #[cfg(not(feature = "parallel"))]
28        {
29            $slice.iter()
30        }
31    }};
32}
33
34use crate::git::types::{FileChange, FileStatus};
35use crate::model::entity::SemanticEntity;
36use crate::parser::import_resolution::{
37    find_import_file, find_import_target, import_source_matches_file, is_js_ts_file,
38    js_ts_import_source_files_from_content, js_ts_named_exports_from_content,
39    sort_import_candidate_files, JS_TS_EXTENSIONS,
40};
41use crate::parser::registry::{resolve_go_method_parent_ids, ParserRegistry};
42use crate::parser::scope_resolve;
43
44#[cfg(not(test))]
45const PARSED_FILE_REUSE_LIMIT: usize = 20_000;
46#[cfg(test)]
47const PARSED_FILE_REUSE_LIMIT: usize = 8;
48#[cfg(not(test))]
49const SCOPE_RESOLVE_FILE_CHUNK_SIZE: usize = 5_000;
50#[cfg(test)]
51const SCOPE_RESOLVE_FILE_CHUNK_SIZE: usize = 3;
52
53#[derive(Clone, Copy)]
54struct ChildRange<'a> {
55    file_path: &'a str,
56    start_line: usize,
57    end_line: usize,
58    start_byte: Option<usize>,
59    end_byte: Option<usize>,
60}
61
62fn build_child_ranges_by_parent<'a>(
63    entities: &'a [SemanticEntity],
64) -> HashMap<&'a str, Vec<ChildRange<'a>>> {
65    let entity_by_id: HashMap<&str, &SemanticEntity> = entities
66        .iter()
67        .map(|entity| (entity.id.as_str(), entity))
68        .collect();
69    let mut line_starts_by_parent: HashMap<&'a str, Vec<usize>> = HashMap::new();
70    let mut child_ranges_by_parent: HashMap<&'a str, Vec<ChildRange<'a>>> = HashMap::new();
71
72    for child in entities {
73        let Some(parent_id) = child.parent_id.as_deref() else {
74            continue;
75        };
76        let (start_byte, end_byte) = entity_by_id
77            .get(parent_id)
78            .and_then(|parent| {
79                let parent_line_starts = line_starts_by_parent
80                    .entry(parent_id)
81                    .or_insert_with(|| line_start_offsets(&parent.content));
82                child_content_span_in_parent(parent, child, parent_line_starts)
83            })
84            .map_or((None, None), |(start, end)| (Some(start), Some(end)));
85
86        child_ranges_by_parent
87            .entry(parent_id)
88            .or_default()
89            .push(ChildRange {
90                file_path: child.file_path.as_str(),
91                start_line: child.start_line,
92                end_line: child.end_line,
93                start_byte,
94                end_byte,
95            });
96    }
97
98    for child_ranges in child_ranges_by_parent.values_mut() {
99        child_ranges.sort_unstable_by(|left, right| {
100            match (left.start_byte, right.start_byte) {
101                (Some(left_start), Some(right_start)) => left_start.cmp(&right_start),
102                (Some(_), None) => std::cmp::Ordering::Less,
103                (None, Some(_)) => std::cmp::Ordering::Greater,
104                (None, None) => std::cmp::Ordering::Equal,
105            }
106            .then_with(|| left.end_byte.cmp(&right.end_byte))
107            .then_with(|| left.file_path.cmp(right.file_path))
108            .then_with(|| left.start_line.cmp(&right.start_line))
109            .then_with(|| left.end_line.cmp(&right.end_line))
110        });
111    }
112
113    child_ranges_by_parent
114}
115
116fn child_content_span_in_parent(
117    parent: &SemanticEntity,
118    child: &SemanticEntity,
119    parent_line_starts: &[usize],
120) -> Option<(usize, usize)> {
121    if parent.file_path != child.file_path || child.content.is_empty() {
122        return None;
123    }
124
125    let expected_local_line = child.start_line.checked_sub(parent.start_line)? + 1;
126    if let Some(span) = child_content_span_at_expected_line(
127        &parent.content,
128        &child.content,
129        expected_local_line,
130        parent_line_starts,
131    ) {
132        return Some(span);
133    }
134
135    for (offset, _) in parent.content.match_indices(&child.content) {
136        let local_line = line_for_byte(&parent.content, offset);
137        if local_line == expected_local_line {
138            return Some((offset, offset + child.content.len()));
139        }
140    }
141
142    None
143}
144
145fn child_content_span_at_expected_line(
146    parent_content: &str,
147    child_content: &str,
148    expected_local_line: usize,
149    parent_line_starts: &[usize],
150) -> Option<(usize, usize)> {
151    let line_start = *parent_line_starts.get(expected_local_line.checked_sub(1)?)?;
152    if let Some(span) = content_span_at(parent_content, child_content, line_start) {
153        return Some(span);
154    }
155
156    let line_end = parent_line_starts
157        .get(expected_local_line)
158        .copied()
159        .map(|next_line_start| next_line_start.saturating_sub(1))
160        .unwrap_or(parent_content.len());
161    let line = parent_content.get(line_start..line_end)?;
162
163    let trimmed_line_start = line_start + line.len().saturating_sub(line.trim_start().len());
164    if trimmed_line_start != line_start {
165        if let Some(span) = content_span_at(parent_content, child_content, trimmed_line_start) {
166            return Some(span);
167        }
168    }
169
170    let first_child_line = child_content
171        .split_once('\n')
172        .map_or(child_content, |(line, _)| line);
173    if first_child_line.is_empty() {
174        return None;
175    }
176
177    for (candidate_offset, _) in line.match_indices(first_child_line) {
178        if let Some(span) =
179            content_span_at(parent_content, child_content, line_start + candidate_offset)
180        {
181            return Some(span);
182        }
183    }
184
185    None
186}
187
188fn content_span_at(content: &str, needle: &str, start: usize) -> Option<(usize, usize)> {
189    let end = start.checked_add(needle.len())?;
190    (content.get(start..end) == Some(needle)).then_some((start, end))
191}
192
193fn entity_owns_content_span(
194    entity_id: &str,
195    file_path: &str,
196    source_line: usize,
197    local_start_byte: Option<usize>,
198    local_end_byte: Option<usize>,
199    child_ranges_by_parent: &HashMap<&str, Vec<ChildRange<'_>>>,
200) -> bool {
201    let Some(child_ranges) = child_ranges_by_parent.get(entity_id) else {
202        return true;
203    };
204
205    let child_has_source_line = |child: &ChildRange<'_>| {
206        child.file_path == file_path
207            && source_line >= child.start_line
208            && source_line <= child.end_line
209    };
210
211    let first_without_byte = child_ranges.partition_point(|child| child.start_byte.is_some());
212    if let (Some(start), Some(end)) = (local_start_byte, local_end_byte) {
213        let byte_ranges = &child_ranges[..first_without_byte];
214        let possible_end = byte_ranges.partition_point(|child| {
215            child
216                .start_byte
217                .is_some_and(|child_start| child_start < end)
218        });
219        for child in byte_ranges[..possible_end].iter().rev() {
220            let (Some(child_start), Some(child_end)) = (child.start_byte, child.end_byte) else {
221                continue;
222            };
223            if child_end <= start {
224                break;
225            }
226            if start < child_end && end > child_start && child_has_source_line(child) {
227                return false;
228            }
229        }
230    } else if child_ranges.iter().any(child_has_source_line) {
231        return false;
232    }
233
234    !child_ranges[first_without_byte..]
235        .iter()
236        .any(child_has_source_line)
237}
238
239fn source_line_for_entity_content(entity: &SemanticEntity, local_line: usize) -> usize {
240    entity.start_line + local_line.saturating_sub(1)
241}
242
243fn entity_requires_content_span_filter(
244    entity: &SemanticEntity,
245    child_ranges_by_parent: &HashMap<&str, Vec<ChildRange<'_>>>,
246) -> bool {
247    entity.start_line == entity.end_line
248        || child_ranges_by_parent
249            .get(entity.id.as_str())
250            .map_or(false, |children| {
251                children.iter().any(|child| {
252                    child.start_line == child.end_line
253                        || child.start_line == entity.start_line
254                        || child.end_line == entity.end_line
255                })
256            })
257}
258
259fn line_for_byte(content: &str, byte: usize) -> usize {
260    1 + content[..byte]
261        .bytes()
262        .filter(|current| *current == b'\n')
263        .count()
264}
265
266fn line_start_offsets(content: &str) -> Vec<usize> {
267    let mut starts = vec![0];
268    for (idx, byte) in content.bytes().enumerate() {
269        if byte == b'\n' {
270            starts.push(idx + 1);
271        }
272    }
273    starts
274}
275
276/// A reference from one entity to another.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct EntityRef {
280    pub from_entity: String,
281    pub to_entity: String,
282    pub ref_type: RefType,
283}
284
285/// Type of reference between entities.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(rename_all = "lowercase")]
288pub enum RefType {
289    /// Function/method call
290    Calls,
291    /// Type reference (extends, implements, field type)
292    TypeRef,
293    /// Import/use statement reference
294    Imports,
295}
296
297/// A complete entity dependency graph for a set of files.
298#[derive(Debug)]
299pub struct EntityGraph {
300    /// All entities indexed by ID
301    pub entities: HashMap<String, EntityInfo>,
302    /// Edges: from_entity → [(to_entity, ref_type)]
303    pub edges: Vec<EntityRef>,
304    /// Reverse index: entity_id → entities that reference it
305    pub dependents: HashMap<String, Vec<String>>,
306    /// Forward index: entity_id → entities it references
307    pub dependencies: HashMap<String, Vec<String>>,
308}
309
310/// Metadata describing repairs made during an incremental graph build.
311#[derive(Debug, Clone, Default, PartialEq, Eq)]
312pub struct IncrementalBuildMetadata {
313    pub repaired_clean_entity_ids: bool,
314    pub recomputed_edge_source_ids: Vec<String>,
315    pub deleted_entity_ids: Vec<String>,
316}
317
318/// Minimal entity info stored in the graph.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct EntityInfo {
322    pub id: String,
323    pub name: String,
324    pub entity_type: String,
325    pub file_path: String,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub parent_id: Option<String>,
328    pub start_line: usize,
329    pub end_line: usize,
330}
331
332fn sort_symbol_table_targets_by_source(
333    symbol_table: &mut HashMap<String, Vec<String>>,
334    entity_map: &HashMap<String, EntityInfo>,
335) {
336    for target_ids in symbol_table.values_mut() {
337        if target_ids.len() > 1 {
338            target_ids.sort_unstable_by(|left, right| {
339                match (entity_map.get(left), entity_map.get(right)) {
340                    (Some(left), Some(right)) => (
341                        left.file_path.as_str(),
342                        left.start_line,
343                        left.end_line,
344                        left.id.as_str(),
345                    )
346                        .cmp(&(
347                            right.file_path.as_str(),
348                            right.start_line,
349                            right.end_line,
350                            right.id.as_str(),
351                        )),
352                    (Some(_), None) => std::cmp::Ordering::Less,
353                    (None, Some(_)) => std::cmp::Ordering::Greater,
354                    (None, None) => left.cmp(right),
355                }
356            });
357        }
358    }
359}
360
361fn dedupe_resolved_edges(
362    combined: Vec<(String, String, RefType)>,
363) -> Vec<(String, String, RefType)> {
364    let mut keep = vec![false; combined.len()];
365    let mut seen_edges: HashSet<(&str, &str)> = HashSet::with_capacity(combined.len());
366    for (index, (from_entity, to_entity, _)) in combined.iter().enumerate() {
367        if seen_edges.insert((from_entity.as_str(), to_entity.as_str())) {
368            keep[index] = true;
369        }
370    }
371    drop(seen_edges);
372
373    combined
374        .into_iter()
375        .enumerate()
376        .filter_map(|(index, edge)| keep[index].then_some(edge))
377        .collect()
378}
379
380#[derive(Debug)]
381struct LineReferenceIndex {
382    words: Vec<IndexedWordRef>,
383    dot_chains: Vec<(u32, u32)>,
384}
385
386#[derive(Debug)]
387struct FileReferenceIndex {
388    tokens: Vec<String>,
389    token_ids: HashMap<String, u32>,
390    lines: Vec<Option<LineReferenceIndex>>,
391}
392
393#[derive(Debug, Clone, Copy)]
394struct IndexedWordRef {
395    token_id: u32,
396    flags: u8,
397}
398
399impl IndexedWordRef {
400    const CALL: u8 = 0b01;
401    const IMPORT: u8 = 0b10;
402}
403
404impl FileReferenceIndex {
405    fn from_content(content: &str) -> Self {
406        let stripped = strip_comments_and_strings(content);
407        let mut index = Self {
408            tokens: Vec::new(),
409            token_ids: HashMap::new(),
410            lines: Vec::new(),
411        };
412        let lines = stripped
413            .lines()
414            .map(|line| LineReferenceIndex::from_stripped_line(line, &mut index))
415            .collect();
416        index.lines = lines;
417        index
418    }
419
420    fn dot_chains_in_ranges(&self, ranges: &[(usize, usize)]) -> Vec<(&str, &str)> {
421        let mut chains = Vec::new();
422        let mut seen: HashSet<(u32, u32)> = HashSet::new();
423        for &(start_line, end_line) in ranges {
424            for line in self.line_range(start_line, end_line) {
425                for (receiver, member) in &line.dot_chains {
426                    let pair = (*receiver, *member);
427                    if seen.insert(pair) {
428                        chains.push((self.token(*receiver), self.token(*member)));
429                    }
430                }
431            }
432        }
433        chains
434    }
435
436    fn refs_with_types_in_ranges(
437        &self,
438        ranges: &[(usize, usize)],
439        own_name: &str,
440    ) -> Vec<(&str, RefType)> {
441        let mut refs = Vec::new();
442        let mut seen: HashMap<u32, u8> = HashMap::new();
443        for &(start_line, end_line) in ranges {
444            for line in self.line_range(start_line, end_line) {
445                for word in &line.words {
446                    let word_text = self.token(word.token_id);
447                    if word_text == own_name {
448                        continue;
449                    }
450                    let first_seen = !seen.contains_key(&word.token_id);
451                    let flags = seen.entry(word.token_id).or_insert(0);
452                    *flags |= word.flags;
453                    if first_seen {
454                        refs.push(word.token_id);
455                    }
456                }
457            }
458        }
459        refs.into_iter()
460            .map(|token_id| {
461                let flags = seen.get(&token_id).copied().unwrap_or_default();
462                let ref_type = if flags & IndexedWordRef::CALL != 0 {
463                    RefType::Calls
464                } else if flags & IndexedWordRef::IMPORT != 0 {
465                    RefType::Imports
466                } else {
467                    RefType::TypeRef
468                };
469                (self.token(token_id), ref_type)
470            })
471            .collect()
472    }
473
474    fn line_range(
475        &self,
476        start_line: usize,
477        end_line: usize,
478    ) -> impl Iterator<Item = &LineReferenceIndex> {
479        let start = start_line.saturating_sub(1).min(self.lines.len());
480        let end = end_line.min(self.lines.len()).max(start);
481        self.lines[start..end].iter().filter_map(Option::as_ref)
482    }
483
484    fn intern(&mut self, token: &str) -> u32 {
485        if let Some(id) = self.token_ids.get(token) {
486            return *id;
487        }
488        let id = self.tokens.len() as u32;
489        self.tokens.push(token.to_string());
490        self.token_ids.insert(token.to_string(), id);
491        id
492    }
493
494    fn token(&self, token_id: u32) -> &str {
495        self.tokens
496            .get(token_id as usize)
497            .map(String::as_str)
498            .unwrap_or("")
499    }
500}
501
502impl LineReferenceIndex {
503    fn from_stripped_line(line: &str, file_index: &mut FileReferenceIndex) -> Option<Self> {
504        let mut words = Vec::new();
505        let mut seen_words: HashSet<u32> = HashSet::new();
506        let import_like = {
507            let trimmed = line.trim();
508            trimmed.starts_with("import ")
509                || trimmed.starts_with("use ")
510                || trimmed.starts_with("from ")
511                || trimmed.starts_with("require(")
512        };
513
514        for (word, end_byte) in identifier_tokens(line) {
515            if !is_reference_word(word) {
516                continue;
517            }
518            let token_id = file_index.intern(word);
519            let mut flags = 0;
520            if line.as_bytes().get(end_byte) == Some(&b'(') {
521                flags |= IndexedWordRef::CALL;
522            }
523            if import_like {
524                flags |= IndexedWordRef::IMPORT;
525            }
526            if seen_words.insert(token_id) {
527                words.push(IndexedWordRef { token_id, flags });
528            } else if let Some(indexed) = words
529                .iter_mut()
530                .find(|indexed| indexed.token_id == token_id)
531            {
532                indexed.flags |= flags;
533            }
534        }
535
536        let dot_chains: Vec<(u32, u32)> = extract_dot_chains(line)
537            .into_iter()
538            .map(|(receiver, member)| (file_index.intern(receiver), file_index.intern(member)))
539            .collect();
540
541        if words.is_empty() && dot_chains.is_empty() {
542            return None;
543        }
544
545        Some(Self { words, dot_chains })
546    }
547}
548
549fn identifier_tokens(line: &str) -> impl Iterator<Item = (&str, usize)> {
550    let mut start = None;
551    let mut chars = line.char_indices();
552
553    std::iter::from_fn(move || {
554        for (idx, ch) in chars.by_ref() {
555            if ch.is_alphanumeric() || ch == '_' {
556                if start.is_none() {
557                    start = Some(idx);
558                }
559            } else if let Some(token_start) = start.take() {
560                return Some((&line[token_start..idx], idx));
561            }
562        }
563
564        start
565            .take()
566            .map(|token_start| (&line[token_start..], line.len()))
567    })
568}
569
570fn is_reference_word(word: &str) -> bool {
571    if word.is_empty() {
572        return false;
573    }
574    if is_keyword(word) || word.len() < 2 {
575        return false;
576    }
577    if word.starts_with(|c: char| c.is_lowercase()) && word.len() < 3 {
578        return false;
579    }
580    if !word.starts_with(|c: char| c.is_alphabetic() || c == '_') {
581        return false;
582    }
583    if is_common_local_name(word) {
584        return false;
585    }
586    true
587}
588
589fn is_function_like_entity_type(entity_type: &str) -> bool {
590    matches!(
591        entity_type,
592        "function" | "method" | "constructor" | "getter" | "setter"
593    )
594}
595
596fn fallback_reference_end_line(entity: &SemanticEntity, has_scope_resolve: bool) -> usize {
597    if !has_scope_resolve || is_function_like_entity_type(&entity.entity_type) {
598        return entity.end_line;
599    }
600
601    let mut prefix_lines = 0usize;
602    for line in entity.content.lines() {
603        prefix_lines += 1;
604        let trimmed = line.trim_end();
605        if line.contains('{') || trimmed.ends_with(':') || trimmed.ends_with(';') {
606            break;
607        }
608        if prefix_lines >= 16 {
609            break;
610        }
611    }
612
613    (entity.start_line + prefix_lines.saturating_sub(1))
614        .min(entity.end_line)
615        .max(entity.start_line)
616}
617
618fn direct_reference_line_ranges(
619    entity: &SemanticEntity,
620    fallback_end_line: usize,
621    child_line_ranges: &HashMap<String, Vec<(usize, usize)>>,
622) -> Vec<(usize, usize)> {
623    let start_line = entity.start_line;
624    let end_line = fallback_end_line.min(entity.end_line).max(start_line);
625    let mut ranges = Vec::new();
626    let mut next_line = start_line;
627
628    if let Some(children) = child_line_ranges.get(&entity.id) {
629        for &(child_start, child_end) in children {
630            if child_end < next_line {
631                continue;
632            }
633            if child_start > end_line {
634                break;
635            }
636
637            let child_start = child_start.max(start_line);
638            let child_end = child_end.min(end_line);
639            if next_line < child_start {
640                ranges.push((next_line, child_start - 1));
641            }
642            next_line = next_line.max(child_end.saturating_add(1));
643            if next_line > end_line {
644                break;
645            }
646        }
647    }
648
649    if next_line <= end_line {
650        ranges.push((next_line, end_line));
651    }
652
653    ranges
654}
655
656struct ReferenceResolutionContext<'a> {
657    symbol_table: &'a HashMap<String, Vec<String>>,
658    entity_map: &'a HashMap<String, EntityInfo>,
659    import_table: &'a HashMap<(String, String), String>,
660    scope_consumed_words: &'a HashMap<String, HashSet<String>>,
661    child_ranges_by_parent: &'a HashMap<&'a str, Vec<ChildRange<'a>>>,
662    child_line_ranges: &'a HashMap<String, Vec<(usize, usize)>>,
663    parent_child_pairs: &'a HashSet<(&'a str, &'a str)>,
664    class_child_names: &'a HashSet<(&'a str, &'a str)>,
665    class_entity_files: &'a HashSet<(&'a str, &'a str)>,
666    enclosing_class: &'a HashMap<&'a str, &'a str>,
667    class_members: &'a HashMap<&'a str, Vec<(&'a str, &'a str)>>,
668}
669
670fn resolve_references_with_file_indexes<'a>(
671    root: &Path,
672    file_paths: &[String],
673    all_entities: &'a [SemanticEntity],
674    needs_resolution: Option<&HashSet<&'a str>>,
675    context: &ReferenceResolutionContext<'a>,
676) -> Vec<(String, String, RefType)> {
677    let mut entities_by_file: HashMap<&'a str, Vec<&'a SemanticEntity>> = HashMap::new();
678    for entity in all_entities {
679        if needs_resolution
680            .as_ref()
681            .is_some_and(|ids| !ids.contains(entity.id.as_str()))
682        {
683            continue;
684        }
685        let ext = entity
686            .file_path
687            .rfind('.')
688            .map(|i| &entity.file_path[i..])
689            .unwrap_or("");
690        if crate::parser::plugins::code::languages::get_language_config(ext).is_none() {
691            continue;
692        }
693        entities_by_file
694            .entry(entity.file_path.as_str())
695            .or_default()
696            .push(entity);
697    }
698
699    let mut sorted_file_paths = file_paths.to_vec();
700    sorted_file_paths.sort_unstable();
701    sorted_file_paths.dedup();
702
703    maybe_par_iter!(sorted_file_paths)
704        .filter_map(|file_path| {
705            let entities = entities_by_file.get(file_path.as_str())?;
706            let needs_index = entities.iter().any(|entity| {
707                !entity_requires_content_span_filter(entity, context.child_ranges_by_parent)
708            });
709            let reference_index = if needs_index {
710                build_file_reference_index(root, file_path)
711            } else {
712                None
713            };
714
715            let mut file_edges = Vec::new();
716            for entity in entities {
717                file_edges.extend(resolve_entity_references(
718                    entity,
719                    reference_index.as_ref(),
720                    context,
721                ));
722            }
723            Some(file_edges)
724        })
725        .collect::<Vec<_>>()
726        .into_iter()
727        .flatten()
728        .collect()
729}
730
731fn build_file_reference_index(root: &Path, file_path: &str) -> Option<FileReferenceIndex> {
732    let ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
733    crate::parser::plugins::code::languages::get_language_config(ext)?;
734    let content = std::fs::read_to_string(root.join(file_path)).ok()?;
735    Some(FileReferenceIndex::from_content(&content))
736}
737
738fn resolve_scopes_in_file_chunks(
739    root: &Path,
740    file_paths: &[String],
741    all_entities: &[SemanticEntity],
742    entity_map: &HashMap<String, EntityInfo>,
743    pre_built: &scope_resolve::PreBuiltLookups,
744    import_table: &HashMap<(String, String), String>,
745) -> (
746    Vec<(String, String, RefType)>,
747    HashMap<String, HashSet<String>>,
748) {
749    let mut all_edges = Vec::new();
750    let mut all_consumed_words: HashMap<String, HashSet<String>> = HashMap::new();
751
752    for chunk in file_paths.chunks(SCOPE_RESOLVE_FILE_CHUNK_SIZE) {
753        if !chunk.iter().any(|file_path| {
754            let ext = file_path.rfind('.').map(|i| &file_path[i..]).unwrap_or("");
755            crate::parser::plugins::code::languages::get_language_config(ext)
756                .and_then(|config| config.scope_resolve)
757                .is_some()
758        }) {
759            continue;
760        }
761
762        let result = scope_resolve::resolve_with_scopes_full(
763            root,
764            chunk,
765            all_entities,
766            entity_map,
767            None,
768            Some(pre_built),
769            Some(import_table),
770            false,
771        );
772        all_edges.extend(result.edges);
773        for (entity_id, words) in result.consumed_words {
774            all_consumed_words
775                .entry(entity_id)
776                .or_default()
777                .extend(words);
778        }
779    }
780
781    (all_edges, all_consumed_words)
782}
783
784fn resolve_entity_references(
785    entity: &SemanticEntity,
786    reference_index: Option<&FileReferenceIndex>,
787    context: &ReferenceResolutionContext<'_>,
788) -> Vec<(String, String, RefType)> {
789    let ext = entity
790        .file_path
791        .rfind('.')
792        .map(|i| &entity.file_path[i..])
793        .unwrap_or("");
794    let Some(language_config) = crate::parser::plugins::code::languages::get_language_config(ext)
795    else {
796        return vec![];
797    };
798    let fallback_end_line =
799        fallback_reference_end_line(entity, language_config.scope_resolve.is_some());
800    let fallback_ranges =
801        direct_reference_line_ranges(entity, fallback_end_line, context.child_line_ranges);
802
803    let mut entity_edges = Vec::new();
804    let mut consumed_words = context
805        .scope_consumed_words
806        .get(&entity.id)
807        .cloned()
808        .unwrap_or_default();
809
810    let reference_index =
811        if entity_requires_content_span_filter(entity, context.child_ranges_by_parent) {
812            None
813        } else {
814            reference_index
815        };
816    let fallback_stripped = if reference_index.is_none() {
817        Some(strip_comments_and_strings(&entity.content))
818    } else {
819        None
820    };
821    let local_bindings =
822        local_binding_names_filtered(&entity.content, ext, |local_line, start, end| {
823            entity_owns_content_span(
824                entity.id.as_str(),
825                entity.file_path.as_str(),
826                source_line_for_entity_content(entity, local_line),
827                Some(start),
828                Some(end),
829                context.child_ranges_by_parent,
830            )
831        });
832
833    let dot_chains: Vec<(&str, &str, Option<(usize, usize, usize)>)> = match reference_index {
834        Some(index) => index
835            .dot_chains_in_ranges(&fallback_ranges)
836            .into_iter()
837            .map(|(receiver, member)| (receiver, member, None))
838            .collect(),
839        None => extract_dot_chains_with_positions(fallback_stripped.as_ref().unwrap())
840            .into_iter()
841            .map(|(receiver, member, line, start, end)| {
842                (receiver, member, Some((line, start, end)))
843            })
844            .collect(),
845    };
846
847    for (receiver, member, position) in &dot_chains {
848        if consumed_words.contains(*member) {
849            continue;
850        }
851        if let Some((local_line, local_start_byte, local_end_byte)) = *position {
852            if !entity_owns_content_span(
853                entity.id.as_str(),
854                entity.file_path.as_str(),
855                source_line_for_entity_content(entity, local_line),
856                Some(local_start_byte),
857                Some(local_end_byte),
858                context.child_ranges_by_parent,
859            ) {
860                continue;
861            }
862        }
863        let edge_count_before = entity_edges.len();
864        if *receiver == "self" || *receiver == "this" {
865            if let Some(class_name) = context.enclosing_class.get(entity.id.as_str()) {
866                if let Some(members) = context.class_members.get(class_name) {
867                    for (name, target_id) in members {
868                        if *name == *member && *target_id != entity.id.as_str() {
869                            entity_edges.push((
870                                entity.id.clone(),
871                                target_id.to_string(),
872                                RefType::Calls,
873                            ));
874                            consumed_words.insert(member.to_string());
875                            break;
876                        }
877                    }
878                }
879            }
880        } else if context
881            .class_entity_files
882            .contains(&(*receiver, entity.file_path.as_str()))
883        {
884            if let Some(members) = context.class_members.get(*receiver) {
885                for (name, target_id) in members {
886                    if *name == *member {
887                        entity_edges.push((
888                            entity.id.clone(),
889                            target_id.to_string(),
890                            RefType::Calls,
891                        ));
892                        consumed_words.insert(member.to_string());
893                        consumed_words.insert(receiver.to_string());
894                        break;
895                    }
896                }
897            }
898        }
899        if entity_edges.len() == edge_count_before {
900            consumed_words.insert(member.to_string());
901        }
902    }
903
904    let refs: Vec<(&str, RefType)> = match reference_index {
905        Some(index) => index.refs_with_types_in_ranges(&fallback_ranges, &entity.name),
906        None => {
907            let stripped = fallback_stripped.as_ref().unwrap();
908            extract_references_with_stripped_filtered(
909                &entity.content,
910                &entity.name,
911                stripped,
912                |local_line, local_start_byte, local_end_byte| {
913                    entity_owns_content_span(
914                        entity.id.as_str(),
915                        entity.file_path.as_str(),
916                        source_line_for_entity_content(entity, local_line),
917                        Some(local_start_byte),
918                        Some(local_end_byte),
919                        context.child_ranges_by_parent,
920                    )
921                },
922            )
923            .into_iter()
924            .map(|ref_name| (ref_name, infer_ref_type(&entity.content, ref_name)))
925            .collect()
926        }
927    };
928    for (ref_name, ref_type) in refs {
929        if consumed_words.contains(ref_name) {
930            continue;
931        }
932        if local_bindings.contains(ref_name) {
933            continue;
934        }
935
936        if context
937            .class_child_names
938            .contains(&(entity.id.as_str(), ref_name))
939        {
940            continue;
941        }
942
943        let import_key = (entity.file_path.clone(), ref_name.to_string());
944        if let Some(import_target_id) = context.import_table.get(&import_key) {
945            if import_target_id != &entity.id
946                && !context
947                    .parent_child_pairs
948                    .contains(&(entity.id.as_str(), import_target_id.as_str()))
949                && !context
950                    .parent_child_pairs
951                    .contains(&(import_target_id.as_str(), entity.id.as_str()))
952            {
953                entity_edges.push((entity.id.clone(), import_target_id.clone(), ref_type));
954            }
955            continue;
956        }
957
958        if let Some(target_ids) = context.symbol_table.get(ref_name) {
959            let target = target_ids.iter().find(|id| {
960                *id != &entity.id
961                    && context
962                        .entity_map
963                        .get(*id)
964                        .map_or(false, |e| e.file_path == entity.file_path)
965            });
966
967            if let Some(target_id) = target {
968                if context
969                    .parent_child_pairs
970                    .contains(&(entity.id.as_str(), target_id.as_str()))
971                    || context
972                        .parent_child_pairs
973                        .contains(&(target_id.as_str(), entity.id.as_str()))
974                {
975                    continue;
976                }
977                entity_edges.push((entity.id.clone(), target_id.clone(), ref_type));
978            }
979        }
980    }
981
982    entity_edges
983}
984
985impl EntityGraph {
986    /// Reconstruct an EntityGraph from pre-loaded parts (e.g. from a cache).
987    pub fn from_parts(entities: HashMap<String, EntityInfo>, edges: Vec<EntityRef>) -> Self {
988        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
989        let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
990        for edge in &edges {
991            dependents
992                .entry(edge.to_entity.clone())
993                .or_default()
994                .push(edge.from_entity.clone());
995            dependencies
996                .entry(edge.from_entity.clone())
997                .or_default()
998                .push(edge.to_entity.clone());
999        }
1000        EntityGraph {
1001            entities,
1002            edges,
1003            dependents,
1004            dependencies,
1005        }
1006    }
1007
1008    /// Build an entity graph from a set of files.
1009    ///
1010    /// Pass 1: Extract all entities from all files using the parser registry.
1011    /// Pass 2: For each entity, find identifier tokens and resolve them against
1012    ///         the symbol table to create reference edges.
1013    pub fn build(
1014        root: &Path,
1015        file_paths: &[String],
1016        registry: &ParserRegistry,
1017    ) -> (Self, Vec<SemanticEntity>) {
1018        let retain_parsed_files = file_paths.len() <= PARSED_FILE_REUSE_LIMIT;
1019        // Pass 1: Extract all entities in parallel (file I/O + tree-sitter parsing)
1020        // Small and medium repos reuse parse trees in scope resolution; large repos
1021        // keep peak memory bounded by reparsing scope chunks.
1022        let per_file: Vec<(
1023            Vec<SemanticEntity>,
1024            Option<(String, String, tree_sitter::Tree)>,
1025        )> = maybe_par_iter!(file_paths)
1026            .filter_map(|file_path| {
1027                let full_path = root.join(file_path);
1028                let content = std::fs::read_to_string(&full_path).ok()?;
1029                if retain_parsed_files {
1030                    let (entities, tree) =
1031                        registry.extract_entities_with_tree(file_path, &content)?;
1032                    let parsed = tree.map(|tree| (file_path.clone(), content, tree));
1033                    Some((entities, parsed))
1034                } else {
1035                    let entities = registry.extract_entities(file_path, &content);
1036                    Some((entities, None))
1037                }
1038            })
1039            .collect();
1040
1041        let mut all_entities: Vec<SemanticEntity> = Vec::new();
1042        let mut parsed_files: Vec<(String, String, tree_sitter::Tree)> = Vec::new();
1043        for (entities, parsed) in per_file {
1044            all_entities.extend(entities);
1045            if let Some(p) = parsed {
1046                parsed_files.push(p);
1047            }
1048        }
1049        resolve_go_method_parent_ids(&mut all_entities);
1050
1051        // Pass A: Build all lookup structures in a single pass over all_entities.
1052        // This merges what was previously 6 separate O(E) iterations.
1053        let mut symbol_table: HashMap<String, Vec<String>> =
1054            HashMap::with_capacity(all_entities.len());
1055        let mut entity_map: HashMap<String, EntityInfo> =
1056            HashMap::with_capacity(all_entities.len());
1057        let mut parent_child_pairs: HashSet<(&str, &str)> = HashSet::new();
1058        let mut child_line_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
1059        let mut class_child_names: HashSet<(&str, &str)> = HashSet::new();
1060        let child_ranges_by_parent = build_child_ranges_by_parent(&all_entities);
1061        let mut class_entity_names: HashSet<&str> = HashSet::new();
1062        let mut class_entity_files: HashSet<(&str, &str)> = HashSet::new();
1063        let mut id_to_name: HashMap<&str, &str> = HashMap::with_capacity(all_entities.len());
1064        let mut scope_entity_ranges: HashMap<String, Vec<(usize, usize, String)>> = HashMap::new();
1065
1066        for entity in &all_entities {
1067            symbol_table
1068                .entry(entity.name.clone())
1069                .or_default()
1070                .push(entity.id.clone());
1071
1072            entity_map.insert(
1073                entity.id.clone(),
1074                EntityInfo {
1075                    id: entity.id.clone(),
1076                    name: entity.name.clone(),
1077                    entity_type: entity.entity_type.clone(),
1078                    file_path: entity.file_path.clone(),
1079                    parent_id: entity.parent_id.clone(),
1080                    start_line: entity.start_line,
1081                    end_line: entity.end_line,
1082                },
1083            );
1084
1085            if let Some(ref pid) = entity.parent_id {
1086                parent_child_pairs.insert((pid.as_str(), entity.id.as_str()));
1087                child_line_ranges
1088                    .entry(pid.clone())
1089                    .or_default()
1090                    .push((entity.start_line, entity.end_line));
1091                class_child_names.insert((pid.as_str(), entity.name.as_str()));
1092            }
1093
1094            if is_nominal_member_container(entity.entity_type.as_str()) {
1095                class_entity_names.insert(entity.name.as_str());
1096                class_entity_files.insert((entity.name.as_str(), entity.file_path.as_str()));
1097            }
1098
1099            id_to_name.insert(entity.id.as_str(), entity.name.as_str());
1100
1101            scope_entity_ranges
1102                .entry(entity.file_path.clone())
1103                .or_default()
1104                .push((entity.start_line, entity.end_line, entity.id.clone()));
1105        }
1106        for ranges in child_line_ranges.values_mut() {
1107            ranges.sort_unstable_by_key(|(start, end)| (*start, *end));
1108        }
1109
1110        // Pass B: Build enclosing_class, class_members, and scope_class_members
1111        // (depends on id_to_name, class_entity_names, and entity_map from Pass A)
1112        let mut enclosing_class: HashMap<&str, &str> = HashMap::new();
1113        let mut class_members: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
1114        let mut scope_class_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
1115        let mut scope_owner_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
1116
1117        for entity in &all_entities {
1118            if let Some(ref pid) = entity.parent_id {
1119                scope_owner_members
1120                    .entry(pid.clone())
1121                    .or_default()
1122                    .push((entity.name.clone(), entity.id.clone()));
1123                if let Some(&parent_name) = id_to_name.get(pid.as_str()) {
1124                    if class_entity_names.contains(parent_name) {
1125                        enclosing_class.insert(entity.id.as_str(), parent_name);
1126                        class_members
1127                            .entry(parent_name)
1128                            .or_default()
1129                            .push((entity.name.as_str(), entity.id.as_str()));
1130                    }
1131                }
1132                // scope_class_members for scope resolver (checks entity_type of parent)
1133                if let Some(parent) = entity_map.get(pid.as_str()) {
1134                    if let Some(owner_name) = scope_resolve::class_member_owner_name(parent) {
1135                        scope_class_members
1136                            .entry(owner_name.to_string())
1137                            .or_default()
1138                            .push((entity.name.clone(), entity.id.clone()));
1139                    }
1140                }
1141            }
1142            // Go receiver-based methods
1143            if entity.entity_type == "method" && entity.file_path.ends_with(".go") {
1144                if let Some(struct_name) = scope_resolve::extract_go_receiver_type(&entity.content)
1145                {
1146                    scope_class_members
1147                        .entry(struct_name)
1148                        .or_default()
1149                        .push((entity.name.clone(), entity.id.clone()));
1150                }
1151            }
1152        }
1153        sort_symbol_table_targets_by_source(&mut symbol_table, &entity_map);
1154        let symbol_table = Arc::new(symbol_table);
1155
1156        // Build import table: (file_path, imported_name) → target entity ID
1157        // e.g. ("io_handler.py", "validate") → "core.py::function::validate"
1158        let import_table = build_import_table(
1159            root,
1160            file_paths,
1161            &symbol_table,
1162            &entity_map,
1163            retain_parsed_files.then_some(parsed_files.as_slice()),
1164        );
1165        // Build owned Go package index for scope resolver
1166        let owned_go_pkg_index: HashMap<String, Vec<(String, String)>> =
1167            if file_paths.iter().any(|f| f.ends_with(".go")) {
1168                let mut idx: HashMap<String, Vec<(String, String)>> = HashMap::new();
1169                for (name, target_ids) in symbol_table.iter() {
1170                    for target_id in target_ids {
1171                        if let Some(entity) = entity_map.get(target_id) {
1172                            let file_stem = entity
1173                                .file_path
1174                                .rsplit('/')
1175                                .next()
1176                                .unwrap_or(&entity.file_path);
1177                            let file_stem = strip_file_ext(file_stem);
1178                            idx.entry(file_stem.to_string())
1179                                .or_default()
1180                                .push((name.clone(), target_id.clone()));
1181                            if let Some(parent_start) = entity.file_path.rfind('/') {
1182                                let parent_path = &entity.file_path[..parent_start];
1183                                if let Some(dir_name_start) = parent_path.rfind('/') {
1184                                    let dir_name = &parent_path[dir_name_start + 1..];
1185                                    if dir_name != file_stem {
1186                                        idx.entry(dir_name.to_string())
1187                                            .or_default()
1188                                            .push((name.clone(), target_id.clone()));
1189                                    }
1190                                } else if !parent_path.is_empty() && parent_path != file_stem {
1191                                    idx.entry(parent_path.to_string())
1192                                        .or_default()
1193                                        .push((name.clone(), target_id.clone()));
1194                                }
1195                            }
1196                        }
1197                    }
1198                }
1199                for entries in idx.values_mut() {
1200                    entries.sort_unstable();
1201                }
1202                idx
1203            } else {
1204                HashMap::new()
1205            };
1206
1207        let pre_built = scope_resolve::PreBuiltLookups {
1208            symbol_table: Arc::clone(&symbol_table),
1209            class_members: scope_class_members,
1210            owner_members: scope_owner_members,
1211            entity_ranges: scope_entity_ranges,
1212            go_pkg_index: owned_go_pkg_index,
1213        };
1214
1215        // Run scope-aware resolver for supported languages (reuse pre-parsed trees)
1216        let has_scope_lang = file_paths.iter().any(|f| {
1217            let ext = f.rfind('.').map(|i| &f[i..]).unwrap_or("");
1218            crate::parser::plugins::code::languages::get_language_config(ext)
1219                .and_then(|c| c.scope_resolve)
1220                .is_some()
1221        });
1222        let (scope_edges, scope_consumed_words) = if has_scope_lang && retain_parsed_files {
1223            let result = scope_resolve::resolve_with_scopes_full(
1224                root,
1225                file_paths,
1226                &all_entities,
1227                &entity_map,
1228                Some(parsed_files),
1229                Some(&pre_built),
1230                Some(&import_table),
1231                false,
1232            );
1233            (result.edges, result.consumed_words)
1234        } else if has_scope_lang {
1235            resolve_scopes_in_file_chunks(
1236                root,
1237                file_paths,
1238                &all_entities,
1239                &entity_map,
1240                &pre_built,
1241                &import_table,
1242            )
1243        } else {
1244            (vec![], HashMap::new())
1245        };
1246
1247        let reference_context = ReferenceResolutionContext {
1248            symbol_table: symbol_table.as_ref(),
1249            entity_map: &entity_map,
1250            import_table: &import_table,
1251            scope_consumed_words: &scope_consumed_words,
1252            child_ranges_by_parent: &child_ranges_by_parent,
1253            child_line_ranges: &child_line_ranges,
1254            parent_child_pairs: &parent_child_pairs,
1255            class_child_names: &class_child_names,
1256            class_entity_files: &class_entity_files,
1257            enclosing_class: &enclosing_class,
1258            class_members: &class_members,
1259        };
1260        let resolved_refs = resolve_references_with_file_indexes(
1261            root,
1262            file_paths,
1263            &all_entities,
1264            None,
1265            &reference_context,
1266        );
1267
1268        let export_edges = build_export_alias_edges(&all_entities, &import_table);
1269
1270        // Merge scope edges with bag-of-words edges, deduplicating
1271        let mut combined: Vec<(String, String, RefType)> = scope_edges;
1272        combined.extend(export_edges);
1273        combined.extend(resolved_refs);
1274        let all_resolved = dedupe_resolved_edges(combined);
1275
1276        // Build edge indexes from resolved references
1277        let mut edges: Vec<EntityRef> = Vec::with_capacity(all_resolved.len());
1278        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
1279        let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
1280
1281        for (from_entity, to_entity, ref_type) in all_resolved {
1282            dependents
1283                .entry(to_entity.clone())
1284                .or_default()
1285                .push(from_entity.clone());
1286            dependencies
1287                .entry(from_entity.clone())
1288                .or_default()
1289                .push(to_entity.clone());
1290            edges.push(EntityRef {
1291                from_entity,
1292                to_entity,
1293                ref_type,
1294            });
1295        }
1296
1297        let graph = EntityGraph {
1298            entities: entity_map,
1299            edges,
1300            dependents,
1301            dependencies,
1302        };
1303
1304        (graph, all_entities)
1305    }
1306
1307    /// Build an entity graph containing dependency edges for selected entities.
1308    ///
1309    /// The graph includes every entity so callers can perform the same lookup and
1310    /// ambiguity checks as a full graph build. Only selected entities contribute
1311    /// outgoing dependency edges.
1312    pub fn build_direct_dependencies<F>(
1313        root: &Path,
1314        file_paths: &[String],
1315        registry: &ParserRegistry,
1316        mut should_resolve: F,
1317    ) -> (Self, Vec<SemanticEntity>)
1318    where
1319        F: FnMut(&EntityInfo) -> bool,
1320    {
1321        let retain_parsed_files = file_paths.len() <= PARSED_FILE_REUSE_LIMIT;
1322        let per_file: Vec<(
1323            Vec<SemanticEntity>,
1324            Option<(String, String, tree_sitter::Tree)>,
1325        )> = maybe_par_iter!(file_paths)
1326            .filter_map(|file_path| {
1327                let content = std::fs::read_to_string(root.join(file_path)).ok()?;
1328                if retain_parsed_files {
1329                    let (entities, tree) =
1330                        registry.extract_entities_with_tree(file_path, &content)?;
1331                    let parsed = tree.map(|tree| (file_path.clone(), content, tree));
1332                    Some((entities, parsed))
1333                } else {
1334                    Some((registry.extract_entities(file_path, &content), None))
1335                }
1336            })
1337            .collect();
1338
1339        let mut all_entities: Vec<SemanticEntity> = Vec::new();
1340        let mut retained_parsed_files: Vec<(String, String, tree_sitter::Tree)> = Vec::new();
1341        for (entities, parsed) in per_file {
1342            all_entities.extend(entities);
1343            if let Some(parsed) = parsed {
1344                retained_parsed_files.push(parsed);
1345            }
1346        }
1347        resolve_go_method_parent_ids(&mut all_entities);
1348
1349        let mut symbol_table: HashMap<String, Vec<String>> =
1350            HashMap::with_capacity(all_entities.len());
1351        let mut entity_map: HashMap<String, EntityInfo> =
1352            HashMap::with_capacity(all_entities.len());
1353        let mut parent_child_pairs: HashSet<(&str, &str)> = HashSet::new();
1354        let mut child_line_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
1355        let mut class_child_names: HashSet<(&str, &str)> = HashSet::new();
1356        let child_ranges_by_parent = build_child_ranges_by_parent(&all_entities);
1357        let mut class_entity_names: HashSet<&str> = HashSet::new();
1358        let mut class_entity_files: HashSet<(&str, &str)> = HashSet::new();
1359        let mut id_to_name: HashMap<&str, &str> = HashMap::with_capacity(all_entities.len());
1360        let mut scope_entity_ranges: HashMap<String, Vec<(usize, usize, String)>> = HashMap::new();
1361
1362        for entity in &all_entities {
1363            symbol_table
1364                .entry(entity.name.clone())
1365                .or_default()
1366                .push(entity.id.clone());
1367
1368            entity_map.insert(
1369                entity.id.clone(),
1370                EntityInfo {
1371                    id: entity.id.clone(),
1372                    name: entity.name.clone(),
1373                    entity_type: entity.entity_type.clone(),
1374                    file_path: entity.file_path.clone(),
1375                    parent_id: entity.parent_id.clone(),
1376                    start_line: entity.start_line,
1377                    end_line: entity.end_line,
1378                },
1379            );
1380
1381            if let Some(ref pid) = entity.parent_id {
1382                parent_child_pairs.insert((pid.as_str(), entity.id.as_str()));
1383                child_line_ranges
1384                    .entry(pid.clone())
1385                    .or_default()
1386                    .push((entity.start_line, entity.end_line));
1387                class_child_names.insert((pid.as_str(), entity.name.as_str()));
1388            }
1389
1390            if is_nominal_member_container(entity.entity_type.as_str()) {
1391                class_entity_names.insert(entity.name.as_str());
1392                class_entity_files.insert((entity.name.as_str(), entity.file_path.as_str()));
1393            }
1394
1395            id_to_name.insert(entity.id.as_str(), entity.name.as_str());
1396
1397            scope_entity_ranges
1398                .entry(entity.file_path.clone())
1399                .or_default()
1400                .push((entity.start_line, entity.end_line, entity.id.clone()));
1401        }
1402        for ranges in child_line_ranges.values_mut() {
1403            ranges.sort_unstable_by_key(|(start, end)| (*start, *end));
1404        }
1405
1406        let mut enclosing_class: HashMap<&str, &str> = HashMap::new();
1407        let mut class_members: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
1408        let mut scope_class_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
1409        let mut scope_owner_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
1410
1411        for entity in &all_entities {
1412            if let Some(ref pid) = entity.parent_id {
1413                scope_owner_members
1414                    .entry(pid.clone())
1415                    .or_default()
1416                    .push((entity.name.clone(), entity.id.clone()));
1417                if let Some(&parent_name) = id_to_name.get(pid.as_str()) {
1418                    if class_entity_names.contains(parent_name) {
1419                        enclosing_class.insert(entity.id.as_str(), parent_name);
1420                        class_members
1421                            .entry(parent_name)
1422                            .or_default()
1423                            .push((entity.name.as_str(), entity.id.as_str()));
1424                    }
1425                }
1426                if let Some(parent) = entity_map.get(pid.as_str()) {
1427                    if let Some(owner_name) = scope_resolve::class_member_owner_name(parent) {
1428                        scope_class_members
1429                            .entry(owner_name.to_string())
1430                            .or_default()
1431                            .push((entity.name.clone(), entity.id.clone()));
1432                    }
1433                }
1434            }
1435            if entity.entity_type == "method" && entity.file_path.ends_with(".go") {
1436                if let Some(struct_name) = scope_resolve::extract_go_receiver_type(&entity.content)
1437                {
1438                    scope_class_members
1439                        .entry(struct_name)
1440                        .or_default()
1441                        .push((entity.name.clone(), entity.id.clone()));
1442                }
1443            }
1444        }
1445        sort_symbol_table_targets_by_source(&mut symbol_table, &entity_map);
1446        let symbol_table = Arc::new(symbol_table);
1447
1448        let mut needs_resolution: HashSet<String> = HashSet::new();
1449        let mut resolve_file_paths: Vec<String> = Vec::new();
1450        let mut resolve_file_set: HashSet<String> = HashSet::new();
1451        let mut entity_ids: Vec<&String> = entity_map.keys().collect();
1452        entity_ids.sort_unstable();
1453        for entity_id in entity_ids {
1454            let Some(entity) = entity_map.get(entity_id) else {
1455                continue;
1456            };
1457            if should_resolve(entity) {
1458                needs_resolution.insert(entity.id.clone());
1459                if resolve_file_set.insert(entity.file_path.clone()) {
1460                    resolve_file_paths.push(entity.file_path.clone());
1461                }
1462            }
1463        }
1464        resolve_file_paths.sort_unstable();
1465
1466        if needs_resolution.is_empty() {
1467            return (
1468                EntityGraph {
1469                    entities: entity_map,
1470                    edges: Vec::new(),
1471                    dependents: HashMap::new(),
1472                    dependencies: HashMap::new(),
1473                },
1474                all_entities,
1475            );
1476        }
1477
1478        let scope_file_paths = if file_paths.len() > PARSED_FILE_REUSE_LIMIT {
1479            let mut scoped = Vec::new();
1480            for chunk in file_paths.chunks(SCOPE_RESOLVE_FILE_CHUNK_SIZE) {
1481                if chunk.iter().any(|file| resolve_file_set.contains(file)) {
1482                    scoped.extend(chunk.iter().cloned());
1483                }
1484            }
1485            scoped
1486        } else {
1487            file_paths.to_vec()
1488        };
1489        let has_scope_lang = resolve_file_paths.iter().any(|f| {
1490            let ext = f.rfind('.').map(|i| &f[i..]).unwrap_or("");
1491            crate::parser::plugins::code::languages::get_language_config(ext)
1492                .and_then(|c| c.scope_resolve)
1493                .is_some()
1494        });
1495        let parsed_files: Vec<(String, String, tree_sitter::Tree)> = if !has_scope_lang {
1496            Vec::new()
1497        } else if !retained_parsed_files.is_empty() && scope_file_paths.len() == file_paths.len() {
1498            retained_parsed_files
1499        } else {
1500            maybe_par_iter!(&scope_file_paths)
1501                .filter_map(|file_path| {
1502                    let content = std::fs::read_to_string(root.join(file_path)).ok()?;
1503                    let (_entities, tree) =
1504                        registry.extract_entities_with_tree(file_path, &content)?;
1505                    tree.map(|tree| (file_path.clone(), content, tree))
1506                })
1507                .collect()
1508        };
1509
1510        let import_table = build_import_table_with_default_export_paths(
1511            root,
1512            &resolve_file_paths,
1513            file_paths,
1514            &symbol_table,
1515            &entity_map,
1516            Some(parsed_files.as_slice()),
1517        );
1518
1519        let owned_go_pkg_index: HashMap<String, Vec<(String, String)>> =
1520            if resolve_file_paths.iter().any(|f| f.ends_with(".go")) {
1521                scope_resolve::build_go_pkg_index(&symbol_table, &entity_map)
1522            } else {
1523                HashMap::new()
1524            };
1525
1526        let pre_built = scope_resolve::PreBuiltLookups {
1527            symbol_table: Arc::clone(&symbol_table),
1528            class_members: scope_class_members,
1529            owner_members: scope_owner_members,
1530            entity_ranges: scope_entity_ranges,
1531            go_pkg_index: owned_go_pkg_index,
1532        };
1533
1534        let needs_resolution_refs: HashSet<&str> =
1535            needs_resolution.iter().map(String::as_str).collect();
1536        let (scope_edges, scope_consumed_words) = if has_scope_lang {
1537            let result = scope_resolve::resolve_with_scopes_full_for_entities(
1538                root,
1539                &scope_file_paths,
1540                &all_entities,
1541                &entity_map,
1542                (!parsed_files.is_empty()).then_some(parsed_files),
1543                Some(&pre_built),
1544                Some(&import_table),
1545                &needs_resolution_refs,
1546            );
1547            (result.edges, result.consumed_words)
1548        } else {
1549            (vec![], HashMap::new())
1550        };
1551
1552        let reference_context = ReferenceResolutionContext {
1553            symbol_table: symbol_table.as_ref(),
1554            entity_map: &entity_map,
1555            import_table: &import_table,
1556            scope_consumed_words: &scope_consumed_words,
1557            child_ranges_by_parent: &child_ranges_by_parent,
1558            child_line_ranges: &child_line_ranges,
1559            parent_child_pairs: &parent_child_pairs,
1560            class_child_names: &class_child_names,
1561            class_entity_files: &class_entity_files,
1562            enclosing_class: &enclosing_class,
1563            class_members: &class_members,
1564        };
1565        let resolved_refs = resolve_references_with_file_indexes(
1566            root,
1567            &resolve_file_paths,
1568            &all_entities,
1569            Some(&needs_resolution_refs),
1570            &reference_context,
1571        );
1572
1573        let export_edges = build_export_alias_edges(&all_entities, &import_table)
1574            .into_iter()
1575            .filter(|(from_entity, _, _)| needs_resolution.contains(from_entity))
1576            .collect::<Vec<_>>();
1577
1578        let mut combined: Vec<(String, String, RefType)> = scope_edges
1579            .into_iter()
1580            .filter(|(from_entity, _, _)| needs_resolution.contains(from_entity))
1581            .collect();
1582        combined.extend(export_edges);
1583        combined.extend(resolved_refs);
1584        let all_resolved = dedupe_resolved_edges(combined);
1585
1586        let mut edges: Vec<EntityRef> = Vec::with_capacity(all_resolved.len());
1587        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
1588        let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
1589
1590        for (from_entity, to_entity, ref_type) in all_resolved {
1591            dependents
1592                .entry(to_entity.clone())
1593                .or_default()
1594                .push(from_entity.clone());
1595            dependencies
1596                .entry(from_entity.clone())
1597                .or_default()
1598                .push(to_entity.clone());
1599            edges.push(EntityRef {
1600                from_entity,
1601                to_entity,
1602                ref_type,
1603            });
1604        }
1605
1606        (
1607            EntityGraph {
1608                entities: entity_map,
1609                edges,
1610                dependents,
1611                dependencies,
1612            },
1613            all_entities,
1614        )
1615    }
1616
1617    /// Incrementally build an entity graph: reparse only stale files, reuse cached data for clean files.
1618    ///
1619    /// Uses the same full 3-phase resolution (scope + dot-chain + bag-of-words) as `build()`,
1620    /// but only runs it for entities in stale files + clean entities whose cached edges
1621    /// pointed into stale files (they need re-resolution since their targets may have changed).
1622    pub fn build_incremental(
1623        root: &Path,
1624        stale_files: &[String],
1625        all_file_paths: &[String],
1626        cached_entities: Vec<SemanticEntity>,
1627        cached_edges: Vec<EntityRef>,
1628        stale_file_cached_entities: Vec<SemanticEntity>,
1629        registry: &ParserRegistry,
1630    ) -> (Self, Vec<SemanticEntity>) {
1631        let (graph, entities, _) = Self::build_incremental_with_metadata(
1632            root,
1633            stale_files,
1634            all_file_paths,
1635            cached_entities,
1636            cached_edges,
1637            stale_file_cached_entities,
1638            registry,
1639        );
1640        (graph, entities)
1641    }
1642
1643    pub fn build_incremental_with_metadata(
1644        root: &Path,
1645        stale_files: &[String],
1646        all_file_paths: &[String],
1647        cached_entities: Vec<SemanticEntity>,
1648        cached_edges: Vec<EntityRef>,
1649        stale_file_cached_entities: Vec<SemanticEntity>,
1650        registry: &ParserRegistry,
1651    ) -> (Self, Vec<SemanticEntity>, IncrementalBuildMetadata) {
1652        Self::build_incremental_with_metadata_and_import_candidates(
1653            root,
1654            stale_files,
1655            all_file_paths,
1656            cached_entities,
1657            cached_edges,
1658            stale_file_cached_entities,
1659            None,
1660            registry,
1661        )
1662    }
1663
1664    pub fn build_incremental_with_metadata_and_import_candidates(
1665        root: &Path,
1666        stale_files: &[String],
1667        all_file_paths: &[String],
1668        cached_entities: Vec<SemanticEntity>,
1669        cached_edges: Vec<EntityRef>,
1670        stale_file_cached_entities: Vec<SemanticEntity>,
1671        cached_importing_stale_files: Option<&[String]>,
1672        registry: &ParserRegistry,
1673    ) -> (Self, Vec<SemanticEntity>, IncrementalBuildMetadata) {
1674        // Build set of stale file paths for quick lookup
1675        let stale_set: HashSet<&str> = stale_files.iter().map(|s| s.as_str()).collect();
1676
1677        // Parse stale files in parallel to get new entities + trees
1678        let per_file: Vec<(
1679            Vec<SemanticEntity>,
1680            Option<(String, String, tree_sitter::Tree)>,
1681        )> = maybe_par_iter!(stale_files)
1682            .filter_map(|file_path| {
1683                let full_path = root.join(file_path);
1684                let content = std::fs::read_to_string(&full_path).ok()?;
1685                let (entities, tree) = registry.extract_entities_with_tree(file_path, &content)?;
1686                let parsed = tree.map(|t| (file_path.clone(), content, t));
1687                Some((entities, parsed))
1688            })
1689            .collect();
1690
1691        let mut new_entities: Vec<SemanticEntity> = Vec::new();
1692        let mut parsed_files: Vec<(String, String, tree_sitter::Tree)> = Vec::new();
1693        for (entities, parsed) in per_file {
1694            new_entities.extend(entities);
1695            if let Some(p) = parsed {
1696                parsed_files.push(p);
1697            }
1698        }
1699
1700        // Merge clean cached entities with newly parsed stale-file entities before
1701        // repairing Go method parents; Go receiver types may live in clean files.
1702        let mut all_entities: Vec<SemanticEntity> = cached_entities
1703            .into_iter()
1704            .chain(new_entities.into_iter())
1705            .collect();
1706        let entity_ids_before_parent_repair: HashSet<String> =
1707            all_entities.iter().map(|e| e.id.clone()).collect();
1708        resolve_go_method_parent_ids(&mut all_entities);
1709        let parent_repaired_ids: HashSet<&str> = all_entities
1710            .iter()
1711            .filter(|e| !entity_ids_before_parent_repair.contains(&e.id))
1712            .map(|e| e.id.as_str())
1713            .collect();
1714        let repaired_clean_entity_ids = all_entities.iter().any(|e| {
1715            parent_repaired_ids.contains(e.id.as_str()) && !stale_set.contains(e.file_path.as_str())
1716        });
1717
1718        // Entity-level diffing: compare repaired stale-file entities against cached versions.
1719        let stale_cached_entity_ids: HashSet<&str> = stale_file_cached_entities
1720            .iter()
1721            .map(|e| e.id.as_str())
1722            .collect();
1723
1724        // Build content_hash lookup from cached stale-file entities
1725        let cached_hashes: HashMap<&str, &str> = stale_file_cached_entities
1726            .iter()
1727            .map(|e| (e.id.as_str(), e.content_hash.as_str()))
1728            .collect();
1729
1730        // Classify new stale-file entities
1731        let mut truly_changed_ids: HashSet<String> = HashSet::new();
1732        let mut content_clean_ids: HashSet<String> = HashSet::new();
1733        for entity in all_entities
1734            .iter()
1735            .filter(|e| stale_set.contains(e.file_path.as_str()))
1736        {
1737            match cached_hashes.get(entity.id.as_str()) {
1738                Some(old_hash) if *old_hash == entity.content_hash.as_str() => {
1739                    content_clean_ids.insert(entity.id.clone());
1740                }
1741                _ => {
1742                    // Hash differs or entity is new
1743                    truly_changed_ids.insert(entity.id.clone());
1744                }
1745            }
1746        }
1747
1748        // Detect deleted entities: in cached stale but not in new
1749        let new_entity_ids: HashSet<&str> = all_entities
1750            .iter()
1751            .filter(|e| stale_set.contains(e.file_path.as_str()))
1752            .map(|e| e.id.as_str())
1753            .collect();
1754        let deleted_ids: HashSet<&str> = stale_file_cached_entities
1755            .iter()
1756            .filter(|e| !new_entity_ids.contains(e.id.as_str()))
1757            .map(|e| e.id.as_str())
1758            .collect();
1759
1760        let mut symbol_table: HashMap<String, Vec<String>> =
1761            HashMap::with_capacity(all_entities.len());
1762        let mut entity_map: HashMap<String, EntityInfo> =
1763            HashMap::with_capacity(all_entities.len());
1764
1765        for entity in &all_entities {
1766            symbol_table
1767                .entry(entity.name.clone())
1768                .or_default()
1769                .push(entity.id.clone());
1770            entity_map.insert(
1771                entity.id.clone(),
1772                EntityInfo {
1773                    id: entity.id.clone(),
1774                    name: entity.name.clone(),
1775                    entity_type: entity.entity_type.clone(),
1776                    file_path: entity.file_path.clone(),
1777                    parent_id: entity.parent_id.clone(),
1778                    start_line: entity.start_line,
1779                    end_line: entity.end_line,
1780                },
1781            );
1782        }
1783        sort_symbol_table_targets_by_source(&mut symbol_table, &entity_map);
1784        let symbol_table = Arc::new(symbol_table);
1785
1786        let entity_file_paths: HashMap<&str, &str> = all_entities
1787            .iter()
1788            .map(|e| (e.id.as_str(), e.file_path.as_str()))
1789            .collect();
1790        let stale_entity_ids: HashSet<&str> = all_entities
1791            .iter()
1792            .filter(|e| stale_set.contains(e.file_path.as_str()))
1793            .map(|e| e.id.as_str())
1794            .collect();
1795        let current_entity_ids: HashSet<&str> =
1796            all_entities.iter().map(|e| e.id.as_str()).collect();
1797        let mut stale_or_cached_stale_entity_ids: HashSet<&str> =
1798            HashSet::with_capacity(stale_entity_ids.len() + stale_cached_entity_ids.len());
1799        stale_or_cached_stale_entity_ids.extend(stale_entity_ids.iter().copied());
1800        stale_or_cached_stale_entity_ids.extend(stale_cached_entity_ids.iter().copied());
1801
1802        let has_new_or_deleted_stale_entities = all_entities.iter().any(|entity| {
1803            stale_set.contains(entity.file_path.as_str())
1804                && !cached_hashes.contains_key(entity.id.as_str())
1805        }) || !deleted_ids.is_empty();
1806
1807        // Find clean entities whose cached outgoing edges are invalidated by stale targets.
1808        let mut affected_clean_ids: HashSet<String> = HashSet::new();
1809        let mut affected_clean_file_paths: HashSet<&str> = HashSet::new();
1810        for edge in &cached_edges {
1811            let to_truly_changed = truly_changed_ids.contains(&edge.to_entity)
1812                || deleted_ids.contains(edge.to_entity.as_str());
1813            let to_stale_file = stale_or_cached_stale_entity_ids.contains(edge.to_entity.as_str());
1814            let from_file_path = entity_file_paths.get(edge.from_entity.as_str()).copied();
1815            let from_clean_file =
1816                from_file_path.is_some_and(|file_path| !stale_set.contains(file_path));
1817
1818            if (to_truly_changed || to_stale_file) && from_clean_file {
1819                affected_clean_ids.insert(edge.from_entity.clone());
1820                if let Some(file_path) = from_file_path {
1821                    affected_clean_file_paths.insert(file_path);
1822                }
1823            }
1824        }
1825
1826        let mut affected_target_names: HashSet<&str> = all_entities
1827            .iter()
1828            .filter(|entity| {
1829                truly_changed_ids.contains(&entity.id)
1830                    || parent_repaired_ids.contains(entity.id.as_str())
1831            })
1832            .map(|entity| entity.name.as_str())
1833            .collect();
1834        affected_target_names.extend(
1835            stale_file_cached_entities
1836                .iter()
1837                .filter(|entity| deleted_ids.contains(entity.id.as_str()))
1838                .map(|entity| entity.name.as_str()),
1839        );
1840
1841        // Clean entities can gain edges to names introduced by stale files even when
1842        // no cached edge existed.
1843        if !affected_target_names.is_empty() {
1844            let affected_target_candidate_files: HashSet<&str> = affected_target_names
1845                .iter()
1846                .filter_map(|name| symbol_table.get(*name))
1847                .flatten()
1848                .filter_map(|entity_id| entity_file_paths.get(entity_id.as_str()).copied())
1849                .filter(|file_path| !stale_set.contains(*file_path))
1850                .collect();
1851
1852            for entity in all_entities.iter().filter(|entity| {
1853                affected_target_candidate_files.contains(entity.file_path.as_str())
1854            }) {
1855                if stale_set.contains(entity.file_path.as_str())
1856                    || affected_clean_ids.contains(&entity.id)
1857                {
1858                    continue;
1859                }
1860
1861                let ext = entity
1862                    .file_path
1863                    .rfind('.')
1864                    .map(|i| &entity.file_path[i..])
1865                    .unwrap_or("");
1866                if crate::parser::plugins::code::languages::get_language_config(ext).is_none() {
1867                    continue;
1868                }
1869
1870                if !text_mentions_any_name(&entity.content, &affected_target_names) {
1871                    continue;
1872                }
1873
1874                let stripped = strip_comments_and_strings(&entity.content);
1875                if text_mentions_any_name(&stripped, &affected_target_names) {
1876                    affected_clean_ids.insert(entity.id.clone());
1877                    affected_clean_file_paths.insert(entity.file_path.as_str());
1878                }
1879            }
1880        }
1881
1882        let import_table = if has_new_or_deleted_stale_entities {
1883            Some(build_import_table(
1884                root,
1885                all_file_paths,
1886                &symbol_table,
1887                &entity_map,
1888                Some(&parsed_files),
1889            ))
1890        } else {
1891            None
1892        };
1893
1894        let mut new_stale_entity_ids: HashSet<&str> = HashSet::new();
1895        let mut new_stale_names: HashSet<&str> = HashSet::new();
1896        for entity in &all_entities {
1897            if stale_set.contains(entity.file_path.as_str())
1898                && !cached_hashes.contains_key(entity.id.as_str())
1899            {
1900                new_stale_entity_ids.insert(entity.id.as_str());
1901                new_stale_names.insert(entity.name.as_str());
1902            }
1903        }
1904        if !new_stale_names.is_empty() {
1905            let import_table = import_table
1906                .as_ref()
1907                .expect("new stale entity analysis requires a full import table");
1908            let new_stale_import_refs: HashSet<(&str, &str)> = import_table
1909                .iter()
1910                .filter(|(_, target_id)| new_stale_entity_ids.contains(target_id.as_str()))
1911                .map(|((file_path, local_name), _)| (file_path.as_str(), local_name.as_str()))
1912                .collect();
1913            let new_stale_file_paths: HashSet<&str> = new_stale_entity_ids
1914                .iter()
1915                .filter_map(|entity_id| entity_file_paths.get(*entity_id).copied())
1916                .collect();
1917            let mut clean_import_candidate_files: HashSet<&str> = new_stale_import_refs
1918                .iter()
1919                .map(|(file_path, _)| *file_path)
1920                .collect();
1921            let mut clean_entities_mentioning_new_stale_names: HashSet<&str> = HashSet::new();
1922            for entity in all_entities
1923                .iter()
1924                .filter(|entity| !stale_set.contains(entity.file_path.as_str()))
1925            {
1926                if !new_stale_names
1927                    .iter()
1928                    .any(|name| content_contains_identifier(&entity.content, name))
1929                {
1930                    continue;
1931                }
1932
1933                let stripped = strip_comments_and_strings(&entity.content);
1934                if text_mentions_any_name(&stripped, &new_stale_names) {
1935                    clean_entities_mentioning_new_stale_names.insert(entity.id.as_str());
1936                    clean_import_candidate_files.insert(entity.file_path.as_str());
1937                }
1938            }
1939
1940            let clean_file_import_tokens: HashMap<&str, Vec<String>> = clean_import_candidate_files
1941                .into_iter()
1942                .filter_map(|file_path| {
1943                    let content = read_import_scan_prefix(&root.join(file_path))?;
1944                    let mut tokens: Vec<String> = new_stale_file_paths
1945                        .iter()
1946                        .flat_map(|stale_file_path| {
1947                            content_import_tokens_for_file(file_path, &content, stale_file_path)
1948                        })
1949                        .collect();
1950                    if tokens.is_empty() {
1951                        return None;
1952                    }
1953                    tokens.sort_unstable();
1954                    tokens.dedup();
1955                    Some((file_path, tokens))
1956                })
1957                .collect();
1958            let mut new_stale_import_refs_by_file: HashMap<&str, Vec<&str>> = HashMap::new();
1959            for (file_path, local_name) in &new_stale_import_refs {
1960                new_stale_import_refs_by_file
1961                    .entry(*file_path)
1962                    .or_default()
1963                    .push(*local_name);
1964            }
1965
1966            for entity in all_entities
1967                .iter()
1968                .filter(|entity| !stale_set.contains(entity.file_path.as_str()))
1969            {
1970                if affected_clean_ids.contains(&entity.id) {
1971                    continue;
1972                }
1973
1974                let entity_mentions_new_stale_name =
1975                    clean_entities_mentioning_new_stale_names.contains(entity.id.as_str());
1976                if !entity_mentions_new_stale_name
1977                    && !clean_file_import_tokens.contains_key(entity.file_path.as_str())
1978                    && !new_stale_import_refs_by_file.contains_key(entity.file_path.as_str())
1979                {
1980                    continue;
1981                }
1982
1983                let import_tokens = clean_file_import_tokens.get(entity.file_path.as_str());
1984                let mentions_new_stale_name = entity_mentions_new_stale_name;
1985                let mentions_new_stale_import_token = import_tokens.map_or(false, |tokens| {
1986                    tokens
1987                        .iter()
1988                        .any(|token| content_contains_identifier(&entity.content, token))
1989                });
1990                let imported_new_stale_ref = new_stale_import_refs_by_file
1991                    .get(entity.file_path.as_str())
1992                    .map_or(false, |local_names| {
1993                        local_names.iter().any(|local_name| {
1994                            content_contains_identifier(&entity.content, local_name)
1995                        })
1996                    });
1997                let refs = extract_references_from_content(&entity.content, &entity.name);
1998                if mentions_new_stale_name
1999                    || mentions_new_stale_import_token
2000                    || imported_new_stale_ref
2001                    || refs.iter().any(|ref_name| {
2002                        new_stale_names.contains(*ref_name)
2003                            || new_stale_import_refs
2004                                .contains(&(entity.file_path.as_str(), *ref_name))
2005                    })
2006                {
2007                    affected_clean_ids.insert(entity.id.clone());
2008                    affected_clean_file_paths.insert(entity.file_path.as_str());
2009                }
2010            }
2011        }
2012
2013        let stale_js_ts_file_paths: Vec<&str> = stale_set
2014            .iter()
2015            .copied()
2016            .filter(|file_path| is_js_ts_file(file_path))
2017            .collect();
2018        if !stale_js_ts_file_paths.is_empty() {
2019            let clean_import_candidate_files: Vec<&str> = match cached_importing_stale_files {
2020                Some(files) => files
2021                    .iter()
2022                    .map(String::as_str)
2023                    .filter(|file_path| !stale_set.contains(*file_path) && is_js_ts_file(file_path))
2024                    .collect(),
2025                None => all_file_paths
2026                    .iter()
2027                    .map(String::as_str)
2028                    .filter(|file_path| !stale_set.contains(*file_path) && is_js_ts_file(file_path))
2029                    .collect(),
2030            };
2031            let clean_js_ts_import_tokens: HashMap<&str, Vec<String>> =
2032                clean_import_candidate_files
2033                    .into_iter()
2034                    .filter_map(|file_path| {
2035                        let content = read_import_scan_prefix(&root.join(file_path))?;
2036                        let mut tokens: Vec<String> = stale_js_ts_file_paths
2037                            .iter()
2038                            .flat_map(|stale_file_path| {
2039                                content_import_tokens_for_file(file_path, &content, stale_file_path)
2040                            })
2041                            .collect();
2042                        if tokens.is_empty() {
2043                            return None;
2044                        }
2045                        tokens.sort_unstable();
2046                        tokens.dedup();
2047                        Some((file_path, tokens))
2048                    })
2049                    .collect();
2050
2051            for entity in all_entities
2052                .iter()
2053                .filter(|entity| !stale_set.contains(entity.file_path.as_str()))
2054            {
2055                if affected_clean_ids.contains(&entity.id) {
2056                    continue;
2057                }
2058                let Some(tokens) = clean_js_ts_import_tokens.get(entity.file_path.as_str()) else {
2059                    continue;
2060                };
2061                if tokens
2062                    .iter()
2063                    .any(|token| content_contains_identifier(&entity.content, token))
2064                {
2065                    affected_clean_ids.insert(entity.id.clone());
2066                    affected_clean_file_paths.insert(entity.file_path.as_str());
2067                }
2068            }
2069        }
2070
2071        let import_table = match import_table {
2072            Some(import_table) => import_table,
2073            None => {
2074                let mut file_paths = stale_files.to_vec();
2075                file_paths.extend(
2076                    affected_clean_file_paths
2077                        .iter()
2078                        .map(|file_path| (*file_path).to_string()),
2079                );
2080                file_paths.sort_unstable();
2081                file_paths.dedup();
2082                build_import_table_with_default_export_paths(
2083                    root,
2084                    &file_paths,
2085                    all_file_paths,
2086                    &symbol_table,
2087                    &entity_map,
2088                    Some(&parsed_files),
2089                )
2090            }
2091        };
2092
2093        // Keep edges where both endpoints are in clean (non-stale) files and from_entity
2094        // is not affected by target changes. Drop ALL cached edges from stale-file entities
2095        // (even content_clean ones) because import/scope context may have changed even when
2096        // entity content didn't. See: https://github.com/Ataraxy-Labs/sem/issues/116
2097        let kept_edges: Vec<EntityRef> = cached_edges
2098            .into_iter()
2099            .filter(|e| {
2100                if !current_entity_ids.contains(e.from_entity.as_str())
2101                    || !current_entity_ids.contains(e.to_entity.as_str())
2102                {
2103                    return false;
2104                }
2105
2106                let from_stale = stale_or_cached_stale_entity_ids.contains(e.from_entity.as_str());
2107                let to_stale = stale_or_cached_stale_entity_ids.contains(e.to_entity.as_str());
2108
2109                if !from_stale && !to_stale && !affected_clean_ids.contains(&e.from_entity) {
2110                    // Both endpoints in clean files, from not affected
2111                    return true;
2112                }
2113                false
2114            })
2115            .collect();
2116
2117        // Set of entity IDs that need resolution: all stale-file entities + affected clean.
2118        // Content-clean stale entities must be re-resolved because import/scope context
2119        // may have changed even if entity body content is identical.
2120        let needs_resolution: HashSet<&str> = all_entities
2121            .iter()
2122            .filter(|e| {
2123                truly_changed_ids.contains(&e.id)
2124                    || content_clean_ids.contains(&e.id)
2125                    || parent_repaired_ids.contains(e.id.as_str())
2126                    || affected_clean_ids.contains(&e.id)
2127            })
2128            .map(|e| e.id.as_str())
2129            .collect();
2130
2131        // Now run the same resolution logic as build() but only for entities in needs_resolution.
2132        // The lookup structures still include ALL entities.
2133
2134        // Build parent-child set
2135        let parent_child_pairs: HashSet<(&str, &str)> = all_entities
2136            .iter()
2137            .filter_map(|e| {
2138                e.parent_id
2139                    .as_ref()
2140                    .map(|pid| (pid.as_str(), e.id.as_str()))
2141            })
2142            .collect();
2143        let mut child_line_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
2144        for entity in &all_entities {
2145            if let Some(pid) = &entity.parent_id {
2146                child_line_ranges
2147                    .entry(pid.clone())
2148                    .or_default()
2149                    .push((entity.start_line, entity.end_line));
2150            }
2151        }
2152        for ranges in child_line_ranges.values_mut() {
2153            ranges.sort_unstable_by_key(|(start, end)| (*start, *end));
2154        }
2155
2156        let class_child_names: HashSet<(&str, &str)> = all_entities
2157            .iter()
2158            .filter_map(|e| {
2159                e.parent_id
2160                    .as_ref()
2161                    .map(|pid| (pid.as_str(), e.name.as_str()))
2162            })
2163            .collect();
2164
2165        let child_ranges_by_parent = build_child_ranges_by_parent(&all_entities);
2166
2167        let class_entity_names: HashSet<&str> = all_entities
2168            .iter()
2169            .filter(|e| is_nominal_member_container(e.entity_type.as_str()))
2170            .map(|e| e.name.as_str())
2171            .collect();
2172        let class_entity_files: HashSet<(&str, &str)> = all_entities
2173            .iter()
2174            .filter(|e| is_nominal_member_container(e.entity_type.as_str()))
2175            .map(|e| (e.name.as_str(), e.file_path.as_str()))
2176            .collect();
2177
2178        let id_to_name: HashMap<&str, &str> = all_entities
2179            .iter()
2180            .map(|e| (e.id.as_str(), e.name.as_str()))
2181            .collect();
2182
2183        let mut enclosing_class: HashMap<&str, &str> = HashMap::new();
2184        let mut class_members: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
2185        let mut scope_class_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
2186        let mut scope_owner_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
2187        let mut scope_entity_ranges: HashMap<String, Vec<(usize, usize, String)>> = HashMap::new();
2188
2189        for entity in &all_entities {
2190            scope_entity_ranges
2191                .entry(entity.file_path.clone())
2192                .or_default()
2193                .push((entity.start_line, entity.end_line, entity.id.clone()));
2194            if let Some(ref pid) = entity.parent_id {
2195                scope_owner_members
2196                    .entry(pid.clone())
2197                    .or_default()
2198                    .push((entity.name.clone(), entity.id.clone()));
2199                if let Some(parent) = entity_map.get(pid.as_str()) {
2200                    if let Some(owner_name) = scope_resolve::class_member_owner_name(parent) {
2201                        scope_class_members
2202                            .entry(owner_name.to_string())
2203                            .or_default()
2204                            .push((entity.name.clone(), entity.id.clone()));
2205                    }
2206                }
2207                if let Some(&parent_name) = id_to_name.get(pid.as_str()) {
2208                    if class_entity_names.contains(parent_name) {
2209                        enclosing_class.insert(entity.id.as_str(), parent_name);
2210                        class_members
2211                            .entry(parent_name)
2212                            .or_default()
2213                            .push((entity.name.as_str(), entity.id.as_str()));
2214                    }
2215                }
2216            }
2217            if entity.entity_type == "method" && entity.file_path.ends_with(".go") {
2218                if let Some(struct_name) = scope_resolve::extract_go_receiver_type(&entity.content)
2219                {
2220                    scope_class_members
2221                        .entry(struct_name)
2222                        .or_default()
2223                        .push((entity.name.clone(), entity.id.clone()));
2224                }
2225            }
2226        }
2227        for members in scope_class_members.values_mut() {
2228            members.sort_unstable();
2229        }
2230        for members in scope_owner_members.values_mut() {
2231            members.sort_unstable();
2232        }
2233        for ranges in scope_entity_ranges.values_mut() {
2234            ranges.sort_unstable();
2235        }
2236
2237        // Run scope-aware resolver only on files that need resolution
2238        let resolve_file_paths: Vec<String> = all_file_paths
2239            .iter()
2240            .filter(|f| {
2241                stale_set.contains(f.as_str()) || affected_clean_file_paths.contains(f.as_str())
2242            })
2243            .cloned()
2244            .collect();
2245
2246        let has_scope_lang = resolve_file_paths.iter().any(|f| {
2247            let ext = f.rfind('.').map(|i| &f[i..]).unwrap_or("");
2248            crate::parser::plugins::code::languages::get_language_config(ext)
2249                .and_then(|c| c.scope_resolve)
2250                .is_some()
2251        });
2252        let (scope_edges, scope_consumed_words) = if has_scope_lang {
2253            // Pass pre-parsed stale-file trees; scope_resolve reads affected clean files from disk
2254            let resolve_set: HashSet<&str> =
2255                resolve_file_paths.iter().map(|s| s.as_str()).collect();
2256            let relevant_parsed: Vec<(String, String, tree_sitter::Tree)> = parsed_files
2257                .into_iter()
2258                .filter(|(fp, _, _)| resolve_set.contains(fp.as_str()))
2259                .collect();
2260            let pre = if relevant_parsed.is_empty() {
2261                None
2262            } else {
2263                Some(relevant_parsed)
2264            };
2265            let owned_go_pkg_index: HashMap<String, Vec<(String, String)>> =
2266                if resolve_file_paths.iter().any(|f| f.ends_with(".go")) {
2267                    scope_resolve::build_go_pkg_index(&symbol_table, &entity_map)
2268                } else {
2269                    HashMap::new()
2270                };
2271            let pre_built = scope_resolve::PreBuiltLookups {
2272                symbol_table: Arc::clone(&symbol_table),
2273                class_members: scope_class_members,
2274                owner_members: scope_owner_members,
2275                entity_ranges: scope_entity_ranges,
2276                go_pkg_index: owned_go_pkg_index,
2277            };
2278            let result = scope_resolve::resolve_with_scopes_full(
2279                root,
2280                &resolve_file_paths,
2281                &all_entities,
2282                &entity_map,
2283                pre,
2284                Some(&pre_built),
2285                Some(&import_table),
2286                false,
2287            );
2288            (result.edges, result.consumed_words)
2289        } else {
2290            (vec![], HashMap::new())
2291        };
2292
2293        let reference_context = ReferenceResolutionContext {
2294            symbol_table: symbol_table.as_ref(),
2295            entity_map: &entity_map,
2296            import_table: &import_table,
2297            scope_consumed_words: &scope_consumed_words,
2298            child_ranges_by_parent: &child_ranges_by_parent,
2299            child_line_ranges: &child_line_ranges,
2300            parent_child_pairs: &parent_child_pairs,
2301            class_child_names: &class_child_names,
2302            class_entity_files: &class_entity_files,
2303            enclosing_class: &enclosing_class,
2304            class_members: &class_members,
2305        };
2306        let resolved_refs = resolve_references_with_file_indexes(
2307            root,
2308            &resolve_file_paths,
2309            &all_entities,
2310            Some(&needs_resolution),
2311            &reference_context,
2312        );
2313
2314        let export_edges = build_export_alias_edges(&all_entities, &import_table);
2315
2316        // Merge scope edges + bag-of-words edges + kept cached edges
2317        let mut combined: Vec<(String, String, RefType)> = scope_edges;
2318        combined.extend(export_edges);
2319        combined.extend(resolved_refs);
2320        let all_resolved = dedupe_resolved_edges(combined);
2321
2322        // Build final edge list: kept edges + newly resolved edges
2323        let mut edges: Vec<EntityRef> = Vec::with_capacity(kept_edges.len() + all_resolved.len());
2324        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
2325        let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
2326
2327        let mut kept_edge_pairs: HashSet<(&str, &str)> = HashSet::with_capacity(kept_edges.len());
2328        for edge in &kept_edges {
2329            kept_edge_pairs.insert((edge.from_entity.as_str(), edge.to_entity.as_str()));
2330        }
2331
2332        let mut new_edges: Vec<(String, String, RefType)> = Vec::with_capacity(all_resolved.len());
2333        for (from_entity, to_entity, ref_type) in all_resolved {
2334            if kept_edge_pairs.contains(&(from_entity.as_str(), to_entity.as_str())) {
2335                continue;
2336            }
2337            new_edges.push((from_entity, to_entity, ref_type));
2338        }
2339        drop(kept_edge_pairs);
2340
2341        // Add kept cached edges
2342        for edge in kept_edges {
2343            dependents
2344                .entry(edge.to_entity.clone())
2345                .or_default()
2346                .push(edge.from_entity.clone());
2347            dependencies
2348                .entry(edge.from_entity.clone())
2349                .or_default()
2350                .push(edge.to_entity.clone());
2351            edges.push(edge);
2352        }
2353
2354        // Add newly resolved edges, dedup against kept edges
2355        for (from_entity, to_entity, ref_type) in new_edges {
2356            dependents
2357                .entry(to_entity.clone())
2358                .or_default()
2359                .push(from_entity.clone());
2360            dependencies
2361                .entry(from_entity.clone())
2362                .or_default()
2363                .push(to_entity.clone());
2364            edges.push(EntityRef {
2365                from_entity,
2366                to_entity,
2367                ref_type,
2368            });
2369        }
2370
2371        let graph = EntityGraph {
2372            entities: entity_map,
2373            edges,
2374            dependents,
2375            dependencies,
2376        };
2377
2378        let mut recomputed_edge_source_ids: Vec<String> = needs_resolution
2379            .iter()
2380            .map(|id| (*id).to_string())
2381            .collect();
2382        recomputed_edge_source_ids.sort_unstable();
2383        recomputed_edge_source_ids.dedup();
2384
2385        let mut deleted_entity_ids: Vec<String> =
2386            deleted_ids.iter().map(|id| (*id).to_string()).collect();
2387        deleted_entity_ids.sort_unstable();
2388        deleted_entity_ids.dedup();
2389
2390        (
2391            graph,
2392            all_entities,
2393            IncrementalBuildMetadata {
2394                repaired_clean_entity_ids,
2395                recomputed_edge_source_ids,
2396                deleted_entity_ids,
2397            },
2398        )
2399    }
2400
2401    /// Get entities that depend on the given entity (reverse deps).
2402    pub fn get_dependents(&self, entity_id: &str) -> Vec<&EntityInfo> {
2403        self.dependents
2404            .get(entity_id)
2405            .map(|ids| ids.iter().filter_map(|id| self.entities.get(id)).collect())
2406            .unwrap_or_default()
2407    }
2408
2409    /// Get entities that the given entity depends on (forward deps).
2410    pub fn get_dependencies(&self, entity_id: &str) -> Vec<&EntityInfo> {
2411        self.dependencies
2412            .get(entity_id)
2413            .map(|ids| ids.iter().filter_map(|id| self.entities.get(id)).collect())
2414            .unwrap_or_default()
2415    }
2416
2417    /// Impact analysis: if the given entity changes, what else might be affected?
2418    /// Returns all transitive dependents (breadth-first), capped at 10k.
2419    pub fn impact_analysis(&self, entity_id: &str) -> Vec<&EntityInfo> {
2420        self.impact_analysis_capped(entity_id, 10_000)
2421    }
2422
2423    /// Depth-limited impact analysis. Returns transitive dependents with their BFS depth.
2424    /// `max_depth == 0` means unlimited. Default depth of 2 covers direct + one transitive level.
2425    pub fn impact_analysis_bounded(
2426        &self,
2427        entity_id: &str,
2428        max_depth: usize,
2429    ) -> Vec<(&EntityInfo, usize)> {
2430        let mut visited: HashSet<&str> = HashSet::new();
2431        let mut queue: std::collections::VecDeque<(&str, usize)> =
2432            std::collections::VecDeque::new();
2433        let mut result = Vec::new();
2434
2435        let start_key = match self.entities.get_key_value(entity_id) {
2436            Some((k, _)) => k.as_str(),
2437            None => return result,
2438        };
2439
2440        queue.push_back((start_key, 0));
2441        visited.insert(start_key);
2442
2443        while let Some((current, depth)) = queue.pop_front() {
2444            if let Some(deps) = self.dependents.get(current) {
2445                let next_depth = depth + 1;
2446                if max_depth > 0 && next_depth > max_depth {
2447                    continue;
2448                }
2449                for dep in deps {
2450                    if visited.insert(dep.as_str()) {
2451                        if let Some(info) = self.entities.get(dep.as_str()) {
2452                            result.push((info, next_depth));
2453                        }
2454                        queue.push_back((dep.as_str(), next_depth));
2455                    }
2456                }
2457            }
2458        }
2459
2460        result
2461    }
2462
2463    /// Impact analysis with a cap on maximum nodes visited.
2464    /// Returns transitive dependents up to the cap. Uses borrowed strings.
2465    pub fn impact_analysis_capped(&self, entity_id: &str, max_visited: usize) -> Vec<&EntityInfo> {
2466        let mut visited: HashSet<&str> = HashSet::new();
2467        let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
2468        let mut result = Vec::new();
2469
2470        let start_key = match self.entities.get_key_value(entity_id) {
2471            Some((k, _)) => k.as_str(),
2472            None => return result,
2473        };
2474
2475        queue.push_back(start_key);
2476        visited.insert(start_key);
2477
2478        while let Some(current) = queue.pop_front() {
2479            if result.len() >= max_visited {
2480                break;
2481            }
2482            if let Some(deps) = self.dependents.get(current) {
2483                for dep in deps {
2484                    if visited.insert(dep.as_str()) {
2485                        if let Some(info) = self.entities.get(dep.as_str()) {
2486                            result.push(info);
2487                        }
2488                        queue.push_back(dep.as_str());
2489                        if result.len() >= max_visited {
2490                            break;
2491                        }
2492                    }
2493                }
2494            }
2495        }
2496
2497        result
2498    }
2499
2500    /// Count transitive dependents without collecting them (faster for large graphs).
2501    /// Uses borrowed strings to avoid allocation overhead.
2502    pub fn impact_count(&self, entity_id: &str, max_count: usize) -> usize {
2503        let mut visited: HashSet<&str> = HashSet::new();
2504        let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
2505        let mut count = 0;
2506
2507        // We need entity_id to live long enough; look it up in our entities map
2508        let start_key = match self.entities.get_key_value(entity_id) {
2509            Some((k, _)) => k.as_str(),
2510            None => return 0,
2511        };
2512
2513        queue.push_back(start_key);
2514        visited.insert(start_key);
2515
2516        while let Some(current) = queue.pop_front() {
2517            if count >= max_count {
2518                break;
2519            }
2520            if let Some(deps) = self.dependents.get(current) {
2521                for dep in deps {
2522                    if visited.insert(dep.as_str()) {
2523                        count += 1;
2524                        queue.push_back(dep.as_str());
2525                        if count >= max_count {
2526                            break;
2527                        }
2528                    }
2529                }
2530            }
2531        }
2532
2533        count
2534    }
2535
2536    /// Filter entities to those that look like tests.
2537    /// Uses name heuristics, file path patterns, and content patterns.
2538    pub fn filter_test_entities(
2539        &self,
2540        entities: &[crate::model::entity::SemanticEntity],
2541    ) -> HashSet<String> {
2542        let mut test_ids = HashSet::new();
2543        for entity in entities {
2544            if is_test_entity(entity) {
2545                test_ids.insert(entity.id.clone());
2546            }
2547        }
2548        test_ids
2549    }
2550
2551    /// Impact analysis filtered to test entities only.
2552    /// Returns transitive dependents that are test functions/methods.
2553    pub fn test_impact(
2554        &self,
2555        entity_id: &str,
2556        all_entities: &[crate::model::entity::SemanticEntity],
2557    ) -> Vec<&EntityInfo> {
2558        let test_ids = self.filter_test_entities(all_entities);
2559        let impact = self.impact_analysis(entity_id);
2560        impact
2561            .into_iter()
2562            .filter(|info| test_ids.contains(&info.id))
2563            .collect()
2564    }
2565
2566    /// Incrementally update the graph from a set of changed files.
2567    ///
2568    /// Instead of rebuilding the entire graph, this only re-extracts entities
2569    /// from changed files and re-resolves their references. This is faster
2570    /// than a full rebuild when only a few files changed.
2571    ///
2572    /// For each changed file:
2573    /// - Deleted: remove all entities from that file, prune edges
2574    /// - Added/Modified: remove old entities, extract new ones, rebuild references
2575    /// - Renamed: update file paths in entity info
2576    pub fn update_from_changes(
2577        &mut self,
2578        changed_files: &[FileChange],
2579        root: &Path,
2580        registry: &ParserRegistry,
2581    ) {
2582        let mut affected_files: HashSet<String> = HashSet::new();
2583        let mut new_entities: Vec<SemanticEntity> = Vec::new();
2584
2585        for change in changed_files {
2586            affected_files.insert(change.file_path.clone());
2587            if let Some(ref old_path) = change.old_file_path {
2588                affected_files.insert(old_path.clone());
2589            }
2590
2591            match change.status {
2592                FileStatus::Deleted => {
2593                    self.remove_entities_for_file(&change.file_path);
2594                }
2595                FileStatus::Renamed => {
2596                    // Update file paths for renamed files
2597                    if let Some(ref old_path) = change.old_file_path {
2598                        self.remove_entities_for_file(old_path);
2599                    }
2600                    // Extract entities from the new file
2601                    if let Some(entities) = self.extract_file_entities(
2602                        &change.file_path,
2603                        change.after_content.as_deref(),
2604                        root,
2605                        registry,
2606                    ) {
2607                        new_entities.extend(entities);
2608                    }
2609                }
2610                FileStatus::Added | FileStatus::Modified => {
2611                    // Remove old entities for this file
2612                    self.remove_entities_for_file(&change.file_path);
2613                    // Extract new entities
2614                    if let Some(entities) = self.extract_file_entities(
2615                        &change.file_path,
2616                        change.after_content.as_deref(),
2617                        root,
2618                        registry,
2619                    ) {
2620                        new_entities.extend(entities);
2621                    }
2622                }
2623            }
2624        }
2625
2626        // Add new entities to the entity map
2627        for entity in &new_entities {
2628            self.entities.insert(
2629                entity.id.clone(),
2630                EntityInfo {
2631                    id: entity.id.clone(),
2632                    name: entity.name.clone(),
2633                    entity_type: entity.entity_type.clone(),
2634                    file_path: entity.file_path.clone(),
2635                    parent_id: entity.parent_id.clone(),
2636                    start_line: entity.start_line,
2637                    end_line: entity.end_line,
2638                },
2639            );
2640        }
2641
2642        // Rebuild the global symbol table from all current entities
2643        let symbol_table = self.build_symbol_table();
2644        let child_ranges_by_parent = build_child_ranges_by_parent(&new_entities);
2645
2646        // Re-resolve references for new entities
2647        for entity in &new_entities {
2648            self.resolve_entity_references(entity, &symbol_table, &child_ranges_by_parent);
2649        }
2650
2651        // Also re-resolve references for entities in OTHER files that might
2652        // reference entities in changed files (their targets may have changed)
2653        let changed_entity_names: HashSet<String> =
2654            new_entities.iter().map(|e| e.name.clone()).collect();
2655
2656        // Find entities in unchanged files that reference any changed entity name
2657        let entities_to_recheck: Vec<String> = self
2658            .entities
2659            .values()
2660            .filter(|e| !affected_files.contains(&e.file_path))
2661            .filter(|e| {
2662                self.dependencies.get(&e.id).map_or(false, |deps| {
2663                    deps.iter().any(|dep_id| {
2664                        self.entities
2665                            .get(dep_id)
2666                            .map_or(false, |dep| changed_entity_names.contains(&dep.name))
2667                    })
2668                })
2669            })
2670            .map(|e| e.id.clone())
2671            .collect();
2672
2673        // We don't have the full SemanticEntity for unchanged files, so we skip
2674        // deep re-resolution here. The forward/reverse indexes are already updated
2675        // by remove_entities_for_file and resolve_entity_references.
2676        // For entities that had dangling references (their target was deleted),
2677        // the edges were already pruned.
2678        let _ = entities_to_recheck; // acknowledge but don't act on for now
2679    }
2680
2681    /// Extract entities from a file, using provided content or reading from disk.
2682    fn extract_file_entities(
2683        &self,
2684        file_path: &str,
2685        content: Option<&str>,
2686        root: &Path,
2687        registry: &ParserRegistry,
2688    ) -> Option<Vec<SemanticEntity>> {
2689        let content = if let Some(c) = content {
2690            c.to_string()
2691        } else {
2692            let full_path = root.join(file_path);
2693            std::fs::read_to_string(&full_path).ok()?
2694        };
2695
2696        Some(registry.extract_entities(file_path, &content))
2697    }
2698
2699    /// Remove all entities belonging to a specific file and prune their edges.
2700    fn remove_entities_for_file(&mut self, file_path: &str) {
2701        // Collect entity IDs to remove
2702        let ids_to_remove: Vec<String> = self
2703            .entities
2704            .values()
2705            .filter(|e| e.file_path == file_path)
2706            .map(|e| e.id.clone())
2707            .collect();
2708
2709        let id_set: HashSet<&str> = ids_to_remove.iter().map(|s| s.as_str()).collect();
2710
2711        // Remove from entity map
2712        for id in &ids_to_remove {
2713            self.entities.remove(id);
2714        }
2715
2716        // Remove edges involving these entities
2717        self.edges.retain(|e| {
2718            !id_set.contains(e.from_entity.as_str()) && !id_set.contains(e.to_entity.as_str())
2719        });
2720
2721        // Clean up dependency/dependent indexes
2722        for id in &ids_to_remove {
2723            // Remove forward deps
2724            if let Some(deps) = self.dependencies.remove(id) {
2725                // Also remove from reverse index
2726                for dep in &deps {
2727                    if let Some(dependents) = self.dependents.get_mut(dep) {
2728                        dependents.retain(|d| d != id);
2729                    }
2730                }
2731            }
2732            // Remove reverse deps
2733            if let Some(deps) = self.dependents.remove(id) {
2734                // Also remove from forward index
2735                for dep in &deps {
2736                    if let Some(dependencies) = self.dependencies.get_mut(dep) {
2737                        dependencies.retain(|d| d != id);
2738                    }
2739                }
2740            }
2741        }
2742    }
2743
2744    /// Build a symbol table from all current entities.
2745    fn build_symbol_table(&self) -> HashMap<String, Vec<String>> {
2746        let mut symbol_table: HashMap<String, Vec<String>> = HashMap::new();
2747        let mut entities = self.entities.values().collect::<Vec<_>>();
2748        entities.sort_unstable_by(|left, right| {
2749            left.file_path
2750                .cmp(&right.file_path)
2751                .then_with(|| left.start_line.cmp(&right.start_line))
2752                .then_with(|| left.end_line.cmp(&right.end_line))
2753                .then_with(|| left.id.cmp(&right.id))
2754        });
2755        for entity in entities {
2756            symbol_table
2757                .entry(entity.name.clone())
2758                .or_default()
2759                .push(entity.id.clone());
2760        }
2761        symbol_table
2762    }
2763
2764    /// Resolve references for a single entity against the symbol table.
2765    fn resolve_entity_references(
2766        &mut self,
2767        entity: &SemanticEntity,
2768        symbol_table: &HashMap<String, Vec<String>>,
2769        child_ranges_by_parent: &HashMap<&str, Vec<ChildRange<'_>>>,
2770    ) {
2771        let stripped = strip_comments_and_strings(&entity.content);
2772        let refs = extract_references_with_stripped_filtered(
2773            &entity.content,
2774            &entity.name,
2775            &stripped,
2776            |local_line, local_start_byte, local_end_byte| {
2777                entity_owns_content_span(
2778                    entity.id.as_str(),
2779                    entity.file_path.as_str(),
2780                    source_line_for_entity_content(entity, local_line),
2781                    Some(local_start_byte),
2782                    Some(local_end_byte),
2783                    child_ranges_by_parent,
2784                )
2785            },
2786        );
2787
2788        for ref_name in refs {
2789            if let Some(target_ids) = symbol_table.get(ref_name) {
2790                let target = target_ids
2791                    .iter()
2792                    .find(|id| {
2793                        *id != &entity.id
2794                            && self
2795                                .entities
2796                                .get(*id)
2797                                .map_or(false, |e| e.file_path == entity.file_path)
2798                    })
2799                    .or_else(|| target_ids.iter().find(|id| *id != &entity.id));
2800
2801                if let Some(target_id) = target {
2802                    let ref_type = infer_ref_type(&entity.content, &ref_name);
2803                    self.edges.push(EntityRef {
2804                        from_entity: entity.id.clone(),
2805                        to_entity: target_id.clone(),
2806                        ref_type,
2807                    });
2808                    self.dependents
2809                        .entry(target_id.clone())
2810                        .or_default()
2811                        .push(entity.id.clone());
2812                    self.dependencies
2813                        .entry(entity.id.clone())
2814                        .or_default()
2815                        .push(target_id.clone());
2816                }
2817            }
2818        }
2819    }
2820}
2821
2822fn is_nominal_member_container(entity_type: &str) -> bool {
2823    matches!(
2824        entity_type,
2825        "class" | "struct" | "interface" | "class_type" | "enum" | "protocol"
2826    )
2827}
2828
2829#[cfg(test)]
2830fn is_scope_member_container(entity_type: &str) -> bool {
2831    matches!(
2832        entity_type,
2833        "class"
2834            | "struct"
2835            | "interface"
2836            | "impl"
2837            | "enum"
2838            | "protocol"
2839            | "object_declaration"
2840            | "companion_object"
2841    )
2842}
2843
2844/// Check if an entity looks like a test based on name, file path, and content patterns.
2845fn is_test_entity(entity: &crate::model::entity::SemanticEntity) -> bool {
2846    let name = &entity.name;
2847    let path = &entity.file_path;
2848    let content = &entity.content;
2849
2850    // Name patterns
2851    if name.starts_with("test_")
2852        || name.starts_with("Test")
2853        || name.ends_with("_test")
2854        || name.ends_with("Test")
2855    {
2856        return true;
2857    }
2858    if name.starts_with("it_") || name.starts_with("describe_") || name.starts_with("spec_") {
2859        return true;
2860    }
2861
2862    // File path patterns
2863    let path_lower = path.to_lowercase();
2864    let in_test_file = path_lower.contains("/test/")
2865        || path_lower.contains("/tests/")
2866        || path_lower.contains("/spec/")
2867        || path_lower.contains("_test.")
2868        || path_lower.contains(".test.")
2869        || path_lower.contains("_spec.")
2870        || path_lower.contains(".spec.");
2871
2872    // Content patterns (test annotations/decorators)
2873    let has_test_marker = content.contains("#[test]")
2874        || content.contains("#[cfg(test)]")
2875        || content.contains("@Test")
2876        || content.contains("@pytest")
2877        || content.contains("@test")
2878        || content.contains("describe(")
2879        || content.contains("it(")
2880        || content.contains("test(");
2881
2882    in_test_file && has_test_marker
2883}
2884
2885fn build_export_alias_edges(
2886    all_entities: &[SemanticEntity],
2887    import_table: &HashMap<(String, String), String>,
2888) -> Vec<(String, String, RefType)> {
2889    all_entities
2890        .iter()
2891        .filter(|entity| entity.entity_type == "export")
2892        .filter_map(|entity| {
2893            let key = (entity.file_path.clone(), entity.name.clone());
2894            let target_id = import_table.get(&key)?;
2895            if target_id == &entity.id {
2896                return None;
2897            }
2898            Some((entity.id.clone(), target_id.clone(), RefType::Imports))
2899        })
2900        .collect()
2901}
2902
2903struct TsDefaultExportTable {
2904    exports_by_file: HashMap<String, String>,
2905    sorted_files: Vec<String>,
2906}
2907
2908struct TsTopLevelEntityTable {
2909    entities_by_file: HashMap<String, Vec<(String, String)>>,
2910    sorted_files: Vec<String>,
2911}
2912
2913struct TsDefaultReExport {
2914    file_path: String,
2915    original_name: String,
2916    module_path: String,
2917}
2918
2919fn build_ts_default_export_table(
2920    file_paths: &[String],
2921    symbol_table: &HashMap<String, Vec<String>>,
2922    entity_map: &HashMap<String, EntityInfo>,
2923    content_map: &HashMap<&str, &str>,
2924) -> TsDefaultExportTable {
2925    // Per-file extraction is independent, so run it in parallel and merge. Each
2926    // file contributes at most one default export (last name wins, as below) plus
2927    // any default re-export records. Collecting preserves file order, so the merged
2928    // result is identical to a sequential scan.
2929    let per_file: Vec<(Option<(String, String)>, Vec<TsDefaultReExport>)> =
2930        maybe_par_iter!(file_paths)
2931            .filter_map(|file_path| {
2932                if !is_js_ts_file(file_path) {
2933                    return None;
2934                }
2935                let content = content_map.get(file_path.as_str()).copied()?;
2936
2937                let mut default_export: Option<(String, String)> = None;
2938                for name in default_export_names_from_content(content) {
2939                    let Some(target_ids) = symbol_table.get(name.as_str()) else {
2940                        continue;
2941                    };
2942                    let target = target_ids.iter().find(|id| {
2943                        entity_map.get(*id).map_or(false, |entity| {
2944                            entity.file_path == *file_path && entity.parent_id.is_none()
2945                        })
2946                    });
2947                    if let Some(target_id) = target {
2948                        default_export = Some((file_path.clone(), target_id.clone()));
2949                    }
2950                }
2951
2952                let re_exports: Vec<TsDefaultReExport> = default_re_exports_from_content(content)
2953                    .into_iter()
2954                    .map(|(original_name, module_path)| TsDefaultReExport {
2955                        file_path: file_path.clone(),
2956                        original_name,
2957                        module_path,
2958                    })
2959                    .collect();
2960
2961                Some((default_export, re_exports))
2962            })
2963            .collect();
2964
2965    let mut default_exports = HashMap::new();
2966    let mut re_exports = Vec::new();
2967    for (default_export, file_re_exports) in per_file {
2968        if let Some((file_path, target_id)) = default_export {
2969            default_exports.insert(file_path, target_id);
2970        }
2971        re_exports.extend(file_re_exports);
2972    }
2973
2974    resolve_ts_default_re_exports(&mut default_exports, re_exports, symbol_table, entity_map);
2975
2976    let sorted_files = sorted_default_export_files(&default_exports);
2977
2978    TsDefaultExportTable {
2979        exports_by_file: default_exports,
2980        sorted_files,
2981    }
2982}
2983
2984fn sorted_default_export_files(default_exports: &HashMap<String, String>) -> Vec<String> {
2985    let mut sorted_files: Vec<String> = default_exports.keys().cloned().collect();
2986    sort_import_candidate_files(&mut sorted_files, JS_TS_EXTENSIONS);
2987    sorted_files
2988}
2989
2990fn build_ts_top_level_entity_table(
2991    entity_map: &HashMap<String, EntityInfo>,
2992) -> TsTopLevelEntityTable {
2993    let mut entities_by_file: HashMap<String, Vec<(String, String)>> = HashMap::new();
2994    for entity in entity_map.values() {
2995        if !is_js_ts_file(&entity.file_path) || entity.parent_id.is_some() {
2996            continue;
2997        }
2998        entities_by_file
2999            .entry(entity.file_path.clone())
3000            .or_default()
3001            .push((entity.name.clone(), entity.id.clone()));
3002    }
3003    for entries in entities_by_file.values_mut() {
3004        entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
3005    }
3006    let mut sorted_files: Vec<String> = entities_by_file.keys().cloned().collect();
3007    sort_import_candidate_files(&mut sorted_files, JS_TS_EXTENSIONS);
3008    TsTopLevelEntityTable {
3009        entities_by_file,
3010        sorted_files,
3011    }
3012}
3013
3014fn resolve_ts_default_re_exports(
3015    default_exports: &mut HashMap<String, String>,
3016    pending: Vec<TsDefaultReExport>,
3017    symbol_table: &HashMap<String, Vec<String>>,
3018    entity_map: &HashMap<String, EntityInfo>,
3019) {
3020    let mut pending = pending;
3021    while !pending.is_empty() {
3022        let sorted_files = sorted_default_export_files(default_exports);
3023        let mut unresolved = Vec::new();
3024        let mut progressed = false;
3025
3026        for re_export in pending {
3027            let target_id = if re_export.original_name == "default" {
3028                find_import_file(
3029                    &sorted_files,
3030                    &re_export.module_path,
3031                    &re_export.file_path,
3032                    JS_TS_EXTENSIONS,
3033                )
3034                .and_then(|target_file| default_exports.get(target_file))
3035                .cloned()
3036            } else {
3037                symbol_table
3038                    .get(&re_export.original_name)
3039                    .and_then(|target_ids| {
3040                        find_import_target(
3041                            target_ids,
3042                            &re_export.module_path,
3043                            &re_export.file_path,
3044                            JS_TS_EXTENSIONS,
3045                            entity_map,
3046                        )
3047                        .cloned()
3048                    })
3049            };
3050
3051            if let Some(target_id) = target_id {
3052                default_exports.insert(re_export.file_path, target_id);
3053                progressed = true;
3054            } else {
3055                unresolved.push(re_export);
3056            }
3057        }
3058
3059        if !progressed {
3060            break;
3061        }
3062        pending = unresolved;
3063    }
3064}
3065
3066fn default_export_names_from_content(content: &str) -> Vec<String> {
3067    static DEFAULT_FUNCTION_RE: LazyLock<Regex> = LazyLock::new(|| {
3068        Regex::new(r"\bexport\s+default\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)")
3069            .unwrap()
3070    });
3071    static DEFAULT_CLASS_RE: LazyLock<Regex> = LazyLock::new(|| {
3072        Regex::new(r"\bexport\s+default\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)").unwrap()
3073    });
3074    static DEFAULT_IDENTIFIER_RE: LazyLock<Regex> =
3075        LazyLock::new(|| Regex::new(r"\bexport\s+default\s+([A-Za-z_$][\w$]*)").unwrap());
3076    static DEFAULT_SPECIFIER_RE: LazyLock<Regex> =
3077        LazyLock::new(|| Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*;?"#).unwrap());
3078
3079    let mut names = Vec::new();
3080    for cap in DEFAULT_FUNCTION_RE.captures_iter(content) {
3081        names.push(cap.get(1).unwrap().as_str().to_string());
3082    }
3083    for cap in DEFAULT_CLASS_RE.captures_iter(content) {
3084        names.push(cap.get(1).unwrap().as_str().to_string());
3085    }
3086    for cap in DEFAULT_IDENTIFIER_RE.captures_iter(content) {
3087        let name = cap.get(1).unwrap();
3088        let line_tail = content[name.end()..]
3089            .split_once('\n')
3090            .map_or(&content[name.end()..], |(line, _)| line);
3091        if only_js_ts_statement_trivia(line_tail) {
3092            names.push(name.as_str().to_string());
3093        }
3094    }
3095    for cap in DEFAULT_SPECIFIER_RE.captures_iter(content) {
3096        let rest = content[cap.get(0).unwrap().end()..].trim_start();
3097        if rest.starts_with("from ") {
3098            continue;
3099        }
3100        let names_str = cap.get(1).unwrap().as_str();
3101        for name_part in names_str.split(',') {
3102            let Some((original_name, local_name)) = parse_js_ts_import_specifier(name_part) else {
3103                continue;
3104            };
3105            if local_name == "default" {
3106                names.push(original_name.to_string());
3107            }
3108        }
3109    }
3110
3111    names
3112}
3113
3114fn default_re_exports_from_content(content: &str) -> Vec<(String, String)> {
3115    static REEXPORT_SPECIFIER_RE: LazyLock<Regex> = LazyLock::new(|| {
3116        Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#).unwrap()
3117    });
3118
3119    let mut re_exports = Vec::new();
3120    for cap in REEXPORT_SPECIFIER_RE.captures_iter(content) {
3121        let names_str = cap.get(1).unwrap().as_str();
3122        let module_path = cap.get(2).unwrap().as_str();
3123        for name_part in names_str.split(',') {
3124            let Some((original_name, local_name)) = parse_js_ts_import_specifier(name_part) else {
3125                continue;
3126            };
3127            if local_name == "default" {
3128                re_exports.push((original_name.to_string(), module_path.to_string()));
3129            }
3130        }
3131    }
3132    re_exports
3133}
3134
3135fn only_js_ts_statement_trivia(mut text: &str) -> bool {
3136    loop {
3137        text = text.trim_start();
3138        if let Some(rest) = text.strip_prefix(';') {
3139            text = rest;
3140            continue;
3141        }
3142        if text.is_empty() {
3143            return true;
3144        }
3145        if text.starts_with("//") {
3146            return true;
3147        }
3148        if let Some(rest) = text.strip_prefix("/*") {
3149            let Some(end) = rest.find("*/") else {
3150                return false;
3151            };
3152            text = &rest[end + 2..];
3153            continue;
3154        }
3155        return false;
3156    }
3157}
3158
3159fn resolve_default_export_target(
3160    default_exports: &TsDefaultExportTable,
3161    module_path: &str,
3162    file_path: &str,
3163) -> Option<String> {
3164    let target_file = find_import_file(
3165        &default_exports.sorted_files,
3166        module_path,
3167        file_path,
3168        JS_TS_EXTENSIONS,
3169    )?;
3170    default_exports.exports_by_file.get(target_file).cloned()
3171}
3172
3173fn parse_js_ts_import_specifier(name_part: &str) -> Option<(&str, &str)> {
3174    let name_part = name_part.trim();
3175    if name_part.is_empty() {
3176        return None;
3177    }
3178
3179    let (original, local) = if let Some(pos) = name_part.find(" as ") {
3180        let original = name_part[..pos].trim();
3181        let local = name_part[pos + 4..].trim();
3182        (original, local)
3183    } else {
3184        (name_part, name_part)
3185    };
3186
3187    let original = original.strip_prefix("type ").unwrap_or(original).trim();
3188    let local = local.strip_prefix("type ").unwrap_or(local).trim();
3189    if original.is_empty() || local.is_empty() {
3190        return None;
3191    }
3192
3193    Some((original, local))
3194}
3195
3196/// Build import table: maps (file_path, imported_name) → target entity ID.
3197///
3198/// Parses `from X import Y` / `import X` / `use X` style statements from entity content
3199/// and resolves Y to the entity it refers to in the symbol table.
3200fn build_import_table(
3201    root: &Path,
3202    file_paths: &[String],
3203    symbol_table: &HashMap<String, Vec<String>>,
3204    entity_map: &HashMap<String, EntityInfo>,
3205    pre_parsed_content: Option<&[(String, String, tree_sitter::Tree)]>,
3206) -> HashMap<(String, String), String> {
3207    build_import_table_with_default_export_paths(
3208        root,
3209        file_paths,
3210        file_paths,
3211        symbol_table,
3212        entity_map,
3213        pre_parsed_content,
3214    )
3215}
3216
3217fn build_import_table_with_default_export_paths(
3218    root: &Path,
3219    file_paths: &[String],
3220    default_export_file_paths: &[String],
3221    symbol_table: &HashMap<String, Vec<String>>,
3222    entity_map: &HashMap<String, EntityInfo>,
3223    pre_parsed_content: Option<&[(String, String, tree_sitter::Tree)]>,
3224) -> HashMap<(String, String), String> {
3225    // Build a content lookup from pre-parsed files to avoid re-reading from disk
3226    let mut content_map: HashMap<&str, &str> = HashMap::new();
3227    if let Some(files) = pre_parsed_content {
3228        content_map.extend(
3229            files
3230                .iter()
3231                .map(|(fp, content, _)| (fp.as_str(), content.as_str())),
3232        );
3233    }
3234    let mut owned_content: HashMap<String, String> = HashMap::new();
3235    let mut content_file_set: HashSet<String> = file_paths.iter().cloned().collect();
3236    if file_paths.len() == default_export_file_paths.len() {
3237        content_file_set.extend(default_export_file_paths.iter().cloned());
3238        for file_path in &content_file_set {
3239            if file_path.ends_with(".go") || content_map.contains_key(file_path.as_str()) {
3240                continue;
3241            }
3242            if let Ok(content) = std::fs::read_to_string(root.join(file_path)) {
3243                owned_content.insert(file_path.clone(), content);
3244            }
3245        }
3246    } else {
3247        let mut content_file_queue: Vec<String> = file_paths.to_vec();
3248        while let Some(file_path) = content_file_queue.pop() {
3249            if !file_path.ends_with(".go")
3250                && !content_map.contains_key(file_path.as_str())
3251                && !owned_content.contains_key(&file_path)
3252            {
3253                if let Ok(content) = std::fs::read_to_string(root.join(&file_path)) {
3254                    owned_content.insert(file_path.clone(), content);
3255                }
3256            }
3257
3258            let Some(content) = content_map
3259                .get(file_path.as_str())
3260                .copied()
3261                .or_else(|| owned_content.get(&file_path).map(String::as_str))
3262            else {
3263                continue;
3264            };
3265            for imported_file in js_ts_import_source_files_from_content(
3266                &file_path,
3267                content,
3268                default_export_file_paths,
3269            ) {
3270                if content_file_set.insert(imported_file.clone()) {
3271                    content_file_queue.push(imported_file);
3272                }
3273            }
3274        }
3275    }
3276    content_map.extend(
3277        owned_content
3278            .iter()
3279            .map(|(file_path, content)| (file_path.as_str(), content.as_str())),
3280    );
3281    let mut content_file_paths: Vec<String> = content_file_set.into_iter().collect();
3282    content_file_paths.sort_unstable();
3283    let ts_default_exports =
3284        build_ts_default_export_table(&content_file_paths, symbol_table, entity_map, &content_map);
3285    let ts_top_level_entities = OnceLock::new();
3286    let ts_exported_names_by_file: Mutex<HashMap<String, Arc<HashSet<String>>>> =
3287        Mutex::new(HashMap::new());
3288
3289    // Go imports are handled entirely by the scope resolver (which uses an indexed approach).
3290    // We no longer need a go_pkg_index here since Go files are skipped below.
3291
3292    // Process files in parallel, each producing local import entries
3293    let per_file_imports: Vec<Vec<((String, String), String)>> = maybe_par_iter!(file_paths)
3294        .filter_map(|file_path| {
3295            // Go imports are handled entirely by the scope resolver — skip here
3296            if file_path.ends_with(".go") {
3297                return None;
3298            }
3299
3300            let Some(content) = content_map.get(file_path.as_str()).copied() else {
3301                return None;
3302            };
3303
3304            let mut local_imports: Vec<((String, String), String)> = Vec::new();
3305
3306            // Join multi-line imports into single logical lines
3307            // e.g. "from .cookies import (\n    foo,\n    bar,\n)" -> "from .cookies import foo, bar"
3308            let mut logical_lines: Vec<String> = Vec::new();
3309            let mut current_line = String::new();
3310            let mut in_parens = false;
3311
3312            for line in content.lines() {
3313                let trimmed = line.trim();
3314                if in_parens {
3315                    // Strip parentheses and comments
3316                    let clean = trimmed.trim_end_matches(|c: char| c == ')' || c == ',');
3317                    let clean = clean.split('#').next().unwrap_or(clean).trim();
3318                    if !clean.is_empty() && clean != "(" {
3319                        current_line.push_str(", ");
3320                        current_line.push_str(clean);
3321                    }
3322                    if trimmed.contains(')') {
3323                        in_parens = false;
3324                        logical_lines.push(std::mem::take(&mut current_line));
3325                    }
3326                } else if trimmed.starts_with("from ") && trimmed.contains(" import ") {
3327                    if trimmed.contains('(') && !trimmed.contains(')') {
3328                        // Multi-line import starts
3329                        in_parens = true;
3330                        // Take everything before the paren
3331                        let before_paren = trimmed.split('(').next().unwrap_or(trimmed);
3332                        current_line = before_paren.trim().to_string();
3333                        // Also grab anything after the paren on this line
3334                        if let Some(after) = trimmed.split('(').nth(1) {
3335                            let after = after.trim().trim_end_matches(')').trim();
3336                            if !after.is_empty() {
3337                                current_line.push(' ');
3338                                current_line.push_str(after);
3339                            }
3340                        }
3341                    } else {
3342                        logical_lines.push(trimmed.to_string());
3343                    }
3344                }
3345            }
3346
3347            for logical_line in &logical_lines {
3348                if let Some(rest) = logical_line.strip_prefix("from ") {
3349                    // Find " import " or " import," (multi-line imports join with comma)
3350                    let import_match = rest.find(" import ")
3351                        .map(|pos| (pos, 8))
3352                        .or_else(|| rest.find(" import,").map(|pos| (pos, 8)));
3353                    if let Some((import_pos, skip)) = import_match {
3354                        let module_path = &rest[..import_pos];
3355                        let names_str = &rest[import_pos + skip..];
3356
3357                        for name_part in names_str.split(',') {
3358                            let name_part = name_part.trim();
3359                            let imported_name = name_part.split_whitespace().next().unwrap_or(name_part);
3360                            // Strip trailing parens/punctuation
3361                            let imported_name = imported_name.trim_matches(|c: char| c == '(' || c == ')' || c == ',');
3362                            if imported_name.is_empty() {
3363                                continue;
3364                            }
3365
3366                            if let Some(target_ids) = symbol_table.get(imported_name) {
3367                                let target = find_import_target(
3368                                    target_ids,
3369                                    module_path,
3370                                    file_path,
3371                                    &[".py"],
3372                                    entity_map,
3373                                );
3374                                if let Some(target_id) = target {
3375                                    local_imports.push((
3376                                        (file_path.clone(), imported_name.to_string()),
3377                                        target_id.clone(),
3378                                    ));
3379                                }
3380                            }
3381                        }
3382                    }
3383                }
3384            }
3385
3386            // JS/TS imports: import { foo, bar as baz } from './module'
3387            //                import Foo from './module'
3388            let is_js_ts = is_js_ts_file(file_path);
3389
3390            if is_js_ts {
3391                static JS_NAMED_RE: LazyLock<Regex> = LazyLock::new(|| {
3392                    Regex::new(
3393                        r#"import\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#,
3394                    )
3395                    .unwrap()
3396                });
3397                static JS_DEFAULT_RE: LazyLock<Regex> = LazyLock::new(|| {
3398                    Regex::new(
3399                        r#"import\s+(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s*,\s*\{[^}]*\})?\s*from\s*['"]([^'"]+)['"]"#,
3400                    )
3401                    .unwrap()
3402                });
3403                static JS_REEXPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
3404                    Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#)
3405                        .unwrap()
3406                });
3407                static JS_NAMESPACE_RE: LazyLock<Regex> = LazyLock::new(|| {
3408                    Regex::new(
3409                        r#"import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]"#,
3410                    )
3411                    .unwrap()
3412                });
3413
3414                for cap in JS_NAMED_RE.captures_iter(content) {
3415                    let names_str = cap.get(1).unwrap().as_str();
3416                    let module_path = cap.get(2).unwrap().as_str();
3417
3418                    for name_part in names_str.split(',') {
3419                        let Some((original_name, local_name)) =
3420                            parse_js_ts_import_specifier(name_part)
3421                        else {
3422                            continue;
3423                        };
3424
3425                        if let Some(target_ids) = symbol_table.get(original_name) {
3426                            let target = find_import_target(
3427                                target_ids,
3428                                module_path,
3429                                file_path,
3430                                JS_TS_EXTENSIONS,
3431                                entity_map,
3432                            );
3433                            if let Some(target_id) = target {
3434                                local_imports.push((
3435                                    (file_path.clone(), local_name.to_string()),
3436                                    target_id.clone(),
3437                                ));
3438                            }
3439                        }
3440                    }
3441                }
3442
3443                for cap in JS_DEFAULT_RE.captures_iter(content) {
3444                    let local_name = cap.get(1).unwrap().as_str();
3445                    let module_path = cap.get(2).unwrap().as_str();
3446
3447                    if let Some(target_id) =
3448                        resolve_default_export_target(&ts_default_exports, module_path, file_path)
3449                    {
3450                        local_imports.push((
3451                            (file_path.clone(), local_name.to_string()),
3452                            target_id,
3453                        ));
3454                    }
3455                }
3456
3457                for cap in JS_NAMESPACE_RE.captures_iter(content) {
3458                    let alias = cap.get(1).unwrap().as_str();
3459                    let module_path = cap.get(2).unwrap().as_str();
3460                    let ts_top_level_entities = ts_top_level_entities
3461                        .get_or_init(|| build_ts_top_level_entity_table(entity_map));
3462                    let Some(target_file) = find_import_file(
3463                        &ts_top_level_entities.sorted_files,
3464                        module_path,
3465                        file_path,
3466                        JS_TS_EXTENSIONS,
3467                    ) else {
3468                        continue;
3469                    };
3470                    let Some(entries) = ts_top_level_entities.entities_by_file.get(target_file)
3471                    else {
3472                        continue;
3473                    };
3474                    let exported_names = {
3475                        let mut cache = ts_exported_names_by_file.lock().unwrap();
3476                        cache
3477                            .entry(target_file.to_string())
3478                            .or_insert_with(|| {
3479                                Arc::new(
3480                                    content_map
3481                                        .get(target_file)
3482                                        .map(|content| js_ts_named_exports_from_content(content))
3483                                        .unwrap_or_default(),
3484                                )
3485                            })
3486                            .clone()
3487                    };
3488                    for (name, target_id) in entries {
3489                        if !exported_names.contains(name) {
3490                            continue;
3491                        }
3492                        local_imports.push((
3493                            (file_path.clone(), format!("{alias}.{name}")),
3494                            target_id.clone(),
3495                        ));
3496                    }
3497                }
3498
3499                for cap in JS_REEXPORT_RE.captures_iter(content) {
3500                    let names_str = cap.get(1).unwrap().as_str();
3501                    let module_path = cap.get(2).unwrap().as_str();
3502
3503                    for name_part in names_str.split(',') {
3504                        let Some((original_name, local_name)) =
3505                            parse_js_ts_import_specifier(name_part)
3506                        else {
3507                            continue;
3508                        };
3509
3510                        let target_id = if original_name == "default" {
3511                            resolve_default_export_target(
3512                                &ts_default_exports,
3513                                module_path,
3514                                file_path,
3515                            )
3516                        } else {
3517                            symbol_table.get(original_name).and_then(|target_ids| {
3518                                find_import_target(
3519                                    target_ids,
3520                                    module_path,
3521                                    file_path,
3522                                    JS_TS_EXTENSIONS,
3523                                    entity_map,
3524                                )
3525                                .cloned()
3526                            })
3527                        };
3528
3529                        if let Some(target_id) = target_id {
3530                            local_imports.push((
3531                                (file_path.clone(), local_name.to_string()),
3532                                target_id,
3533                            ));
3534                        }
3535                    }
3536                }
3537            }
3538
3539            // Rust imports: use crate::module::Name; / use crate::module::{A, B};
3540            // Also: use super::module::Name; / use self::module::Name;
3541            let is_rust = file_path.ends_with(".rs");
3542            if is_rust {
3543                static RUST_USE_SIMPLE_RE: LazyLock<Regex> = LazyLock::new(|| {
3544                    // use crate::config::Config;
3545                    // use super::types::Entity;
3546                    // use config::Config;  (bare module path in binary crates)
3547                    Regex::new(r"(?m)^\s*use\s+(?:(?:crate|super|self)::)?([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*;").unwrap()
3548                });
3549                static RUST_USE_GROUP_RE: LazyLock<Regex> = LazyLock::new(|| {
3550                    // use crate::types::{Entity, ParseError};
3551                    // use types::{Entity, ParseError};  (bare module path)
3552                    Regex::new(r"(?m)^\s*use\s+(?:(?:crate|super|self)::)?([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)::\{([^}]+)\}\s*;").unwrap()
3553                });
3554
3555                // Use a local import table for Rust alias resolution
3556                let mut local_import_table: HashMap<(String, String), String> = HashMap::new();
3557
3558                // Build a map: module_name -> list of file paths whose stem matches
3559                // For "use crate::config::Config", module is "config", name is "Config"
3560                for cap in RUST_USE_SIMPLE_RE.captures_iter(content) {
3561                    let full_path_str = cap.get(1).unwrap().as_str();
3562                    let parts: Vec<&str> = full_path_str.split("::").collect();
3563                    if parts.is_empty() { continue; }
3564
3565                    // Last part is the imported name, everything before is the module path
3566                    let imported_name = parts[parts.len() - 1];
3567                    // The module is the second-to-last part, or the first if only one part
3568                    let source_module = if parts.len() >= 2 {
3569                        parts[parts.len() - 2]
3570                    } else {
3571                        parts[0]
3572                    };
3573
3574                    resolve_rust_import(
3575                        file_path, imported_name, source_module,
3576                        symbol_table, entity_map, &mut local_import_table,
3577                    );
3578                }
3579
3580                for cap in RUST_USE_GROUP_RE.captures_iter(content) {
3581                    let module_path = cap.get(1).unwrap().as_str();
3582                    let names_str = cap.get(2).unwrap().as_str();
3583
3584                    // source_module is the last segment of the module path
3585                    let source_module = module_path.rsplit("::").next().unwrap_or(module_path);
3586
3587                    for name_part in names_str.split(',') {
3588                        let name_part = name_part.trim();
3589                        // Handle "Name as Alias"
3590                        let (original, local) = if let Some(pos) = name_part.find(" as ") {
3591                            (&name_part[..pos], name_part[pos + 4..].trim())
3592                        } else {
3593                            (name_part, name_part)
3594                        };
3595                        let original = original.trim();
3596                        let local = local.trim();
3597                        if original.is_empty() || local.is_empty() { continue; }
3598
3599                        resolve_rust_import(
3600                            file_path, original, source_module,
3601                            symbol_table, entity_map, &mut local_import_table,
3602                        );
3603                        // If aliased, also map the local name
3604                        if local != original {
3605                            if let Some(target) = local_import_table.get(&(file_path.clone(), original.to_string())).cloned() {
3606                                local_import_table.insert(
3607                                    (file_path.clone(), local.to_string()),
3608                                    target,
3609                                );
3610                            }
3611                        }
3612                    }
3613                }
3614
3615                // Collect all Rust imports into local_imports
3616                for (key, val) in local_import_table {
3617                    local_imports.push((key, val));
3618                }
3619            }
3620
3621            // Go imports are handled by the scope resolver (avoids O(n²) import table explosion).
3622            // Skip Go files here entirely.
3623
3624            Some(local_imports)
3625        })
3626        .collect();
3627
3628    // Merge all per-file imports into a single table
3629    let mut import_table: HashMap<(String, String), String> = HashMap::new();
3630    for local_imports in per_file_imports {
3631        for (key, val) in local_imports {
3632            import_table.insert(key, val);
3633        }
3634    }
3635
3636    import_table
3637}
3638
3639/// Resolve a Rust import: find the target entity in the symbol table
3640/// by matching the imported name against entities in files whose stem matches source_module.
3641fn resolve_rust_import(
3642    file_path: &str,
3643    imported_name: &str,
3644    source_module: &str,
3645    symbol_table: &HashMap<String, Vec<String>>,
3646    entity_map: &HashMap<String, EntityInfo>,
3647    import_table: &mut HashMap<(String, String), String>,
3648) {
3649    if let Some(target_ids) = symbol_table.get(imported_name) {
3650        let target = target_ids.iter().find(|id| {
3651            entity_map.get(*id).map_or(false, |e| {
3652                let stem = e.file_path.rsplit('/').next().unwrap_or(&e.file_path);
3653                let stem = strip_file_ext(stem);
3654                stem == source_module
3655            })
3656        });
3657        if let Some(target_id) = target {
3658            import_table.insert(
3659                (file_path.to_string(), imported_name.to_string()),
3660                target_id.clone(),
3661            );
3662        }
3663    }
3664}
3665
3666/// Strip common file extensions from a filename.
3667fn strip_file_ext(s: &str) -> &str {
3668    s.strip_suffix(".py")
3669        .or_else(|| s.strip_suffix(".ts"))
3670        .or_else(|| s.strip_suffix(".js"))
3671        .or_else(|| s.strip_suffix(".tsx"))
3672        .or_else(|| s.strip_suffix(".jsx"))
3673        .or_else(|| s.strip_suffix(".rs"))
3674        .unwrap_or(s)
3675}
3676
3677/// Strip comments and string literals from content to avoid false references.
3678/// Returns a new string with comments/docstrings replaced by spaces.
3679fn blank_span_preserving_newlines(result: &mut [u8], bytes: &[u8], start: usize, end: usize) {
3680    for idx in start..end.min(bytes.len()) {
3681        result[idx] = if bytes[idx] == b'\n' { b'\n' } else { b' ' };
3682    }
3683}
3684
3685fn strip_comments_and_strings(content: &str) -> String {
3686    let bytes = content.as_bytes();
3687    let len = bytes.len();
3688    let mut result = vec![b' '; len];
3689    let mut i = 0;
3690
3691    while i < len {
3692        // Triple-quoted strings (Python docstrings)
3693        if i + 2 < len && bytes[i] == b'"' && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
3694            let span_start = i;
3695            i += 3;
3696            while i < len {
3697                if i + 2 < len && bytes[i] == b'"' && bytes[i + 1] == b'"' && bytes[i + 2] == b'"' {
3698                    i += 3;
3699                    break;
3700                }
3701                if bytes[i] == b'\n' {
3702                    result[i] = b'\n';
3703                }
3704                i += 1;
3705            }
3706            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3707            continue;
3708        }
3709        if i + 2 < len && bytes[i] == b'\'' && bytes[i + 1] == b'\'' && bytes[i + 2] == b'\'' {
3710            let span_start = i;
3711            i += 3;
3712            while i < len {
3713                if i + 2 < len
3714                    && bytes[i] == b'\''
3715                    && bytes[i + 1] == b'\''
3716                    && bytes[i + 2] == b'\''
3717                {
3718                    i += 3;
3719                    break;
3720                }
3721                if bytes[i] == b'\n' {
3722                    result[i] = b'\n';
3723                }
3724                i += 1;
3725            }
3726            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3727            continue;
3728        }
3729        // Double-quoted strings
3730        if bytes[i] == b'"' {
3731            let span_start = i;
3732            i += 1;
3733            while i < len {
3734                if bytes[i] == b'\\' {
3735                    i = (i + 2).min(len);
3736                    continue;
3737                }
3738                if bytes[i] == b'"' {
3739                    i += 1;
3740                    break;
3741                }
3742                if bytes[i] == b'\n' {
3743                    result[i] = b'\n';
3744                }
3745                i += 1;
3746            }
3747            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3748            continue;
3749        }
3750        // Single-quoted strings
3751        if bytes[i] == b'\'' {
3752            let span_start = i;
3753            i += 1;
3754            while i < len {
3755                if bytes[i] == b'\\' {
3756                    i = (i + 2).min(len);
3757                    continue;
3758                }
3759                if bytes[i] == b'\'' {
3760                    i += 1;
3761                    break;
3762                }
3763                if bytes[i] == b'\n' {
3764                    result[i] = b'\n';
3765                }
3766                i += 1;
3767            }
3768            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3769            continue;
3770        }
3771        // Python/Ruby single-line comments
3772        if bytes[i] == b'#' {
3773            let span_start = i;
3774            while i < len && bytes[i] != b'\n' {
3775                i += 1;
3776            }
3777            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3778            continue;
3779        }
3780        // C-style single-line comments
3781        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' {
3782            let span_start = i;
3783            while i < len && bytes[i] != b'\n' {
3784                i += 1;
3785            }
3786            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3787            continue;
3788        }
3789        // C-style block comments
3790        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
3791            let span_start = i;
3792            i += 2;
3793            while i < len {
3794                if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
3795                    i += 2;
3796                    break;
3797                }
3798                i += 1;
3799            }
3800            blank_span_preserving_newlines(&mut result, bytes, span_start, i);
3801            continue;
3802        }
3803        // Regular code: copy through
3804        result[i] = bytes[i];
3805        i += 1;
3806    }
3807
3808    String::from_utf8(result).expect("stripped source preserves UTF-8 boundaries")
3809}
3810
3811/// Extract dot-chains (receiver.member) from content for precise resolution.
3812/// Returns unique (receiver, member) pairs found in the content.
3813fn extract_dot_chains<'a>(content: &'a str) -> Vec<(&'a str, &'a str)> {
3814    extract_dot_chains_with_positions(content)
3815        .into_iter()
3816        .map(|(receiver, member, _, _, _)| (receiver, member))
3817        .collect()
3818}
3819
3820/// Returns unique receiver/member pairs with one-based content lines and byte offsets.
3821fn extract_dot_chains_with_positions<'a>(
3822    content: &'a str,
3823) -> Vec<(&'a str, &'a str, usize, usize, usize)> {
3824    static DOT_CHAIN_RE: LazyLock<Regex> =
3825        LazyLock::new(|| Regex::new(r"\b([A-Za-z_]\w*)\.([A-Za-z_]\w*)").unwrap());
3826
3827    let mut chains = Vec::new();
3828    let mut seen: HashSet<(&str, &str, usize, usize)> = HashSet::new();
3829    for cap in DOT_CHAIN_RE.captures_iter(content) {
3830        let matched = cap.get(0).unwrap();
3831        let line = line_for_byte(content, matched.start());
3832        let receiver = cap.get(1).unwrap().as_str();
3833        let member = cap.get(2).unwrap().as_str();
3834        if seen.insert((receiver, member, line, matched.start())) {
3835            chains.push((receiver, member, line, matched.start(), matched.end()));
3836        }
3837    }
3838    chains
3839}
3840
3841fn local_binding_names_filtered<F>(
3842    content: &str,
3843    ext: &str,
3844    mut include_token: F,
3845) -> HashSet<String>
3846where
3847    F: FnMut(usize, usize, usize) -> bool,
3848{
3849    let mut names = HashSet::new();
3850    if !matches!(ext, ".js" | ".jsx" | ".ts" | ".tsx" | ".py" | ".swift") {
3851        return names;
3852    }
3853
3854    let mut line_no = 1;
3855    let mut line_start = 0;
3856    for chunk in content.split_inclusive('\n') {
3857        let line = chunk.strip_suffix('\n').unwrap_or(chunk);
3858        match ext {
3859            ".js" | ".jsx" | ".ts" | ".tsx" | ".swift" => {
3860                collect_local_binding_captures(
3861                    line,
3862                    line_no,
3863                    line_start,
3864                    &JS_TS_SWIFT_LOCAL_DECL_RE,
3865                    &mut include_token,
3866                    &mut names,
3867                );
3868            }
3869            ".py" => {
3870                collect_python_local_bindings(
3871                    line,
3872                    line_no,
3873                    line_start,
3874                    &mut include_token,
3875                    &mut names,
3876                );
3877            }
3878            _ => {}
3879        }
3880        line_start += chunk.len();
3881        line_no += 1;
3882    }
3883
3884    names
3885}
3886
3887static JS_TS_SWIFT_LOCAL_DECL_RE: LazyLock<Regex> =
3888    LazyLock::new(|| Regex::new(r"\b(?:const|let|var)\s+([A-Za-z_]\w*)").unwrap());
3889
3890static PY_LOCAL_ASSIGN_RE: LazyLock<Regex> =
3891    LazyLock::new(|| Regex::new(r"^\s*([A-Za-z_]\w*)\s*(?::[^=]+)?([+\-*/%&|^]?=)").unwrap());
3892
3893static PY_FOR_BINDING_RE: LazyLock<Regex> =
3894    LazyLock::new(|| Regex::new(r"^\s*for\s+([A-Za-z_]\w*)\s+in\b").unwrap());
3895
3896fn collect_local_binding_captures<F>(
3897    line: &str,
3898    line_no: usize,
3899    line_start: usize,
3900    regex: &Regex,
3901    include_token: &mut F,
3902    names: &mut HashSet<String>,
3903) where
3904    F: FnMut(usize, usize, usize) -> bool,
3905{
3906    for cap in regex.captures_iter(line) {
3907        if let Some(name_match) = cap.get(1) {
3908            maybe_add_local_binding_name(
3909                name_match.as_str(),
3910                line_no,
3911                line_start,
3912                name_match,
3913                include_token,
3914                names,
3915            );
3916        }
3917    }
3918}
3919
3920fn collect_python_local_bindings<F>(
3921    line: &str,
3922    line_no: usize,
3923    line_start: usize,
3924    include_token: &mut F,
3925    names: &mut HashSet<String>,
3926) where
3927    F: FnMut(usize, usize, usize) -> bool,
3928{
3929    if let Some(cap) = PY_LOCAL_ASSIGN_RE.captures(line) {
3930        if let (Some(name_match), Some(op_match)) = (cap.get(1), cap.get(2)) {
3931            if line.as_bytes().get(op_match.end()) != Some(&b'=') {
3932                maybe_add_local_binding_name(
3933                    name_match.as_str(),
3934                    line_no,
3935                    line_start,
3936                    name_match,
3937                    include_token,
3938                    names,
3939                );
3940            }
3941        }
3942    }
3943
3944    collect_local_binding_captures(
3945        line,
3946        line_no,
3947        line_start,
3948        &PY_FOR_BINDING_RE,
3949        include_token,
3950        names,
3951    );
3952}
3953
3954fn maybe_add_local_binding_name<F>(
3955    name: &str,
3956    line_no: usize,
3957    line_start: usize,
3958    name_match: regex::Match<'_>,
3959    include_token: &mut F,
3960    names: &mut HashSet<String>,
3961) where
3962    F: FnMut(usize, usize, usize) -> bool,
3963{
3964    if !is_reference_word(name) {
3965        return;
3966    }
3967    let start = line_start + name_match.start();
3968    let end = line_start + name_match.end();
3969    if include_token(line_no, start, end) {
3970        names.insert(name.to_string());
3971    }
3972}
3973
3974/// Extract identifier references from entity content using simple token analysis.
3975/// Strips comments and strings first to avoid false positives from docstrings.
3976/// Returns borrowed slices from the stripped content.
3977fn extract_references_from_content<'a>(content: &'a str, own_name: &str) -> Vec<&'a str> {
3978    let stripped = strip_comments_and_strings(content);
3979    extract_references_with_stripped(content, own_name, &stripped)
3980}
3981
3982fn text_mentions_any_name(text: &str, names: &HashSet<&str>) -> bool {
3983    text.split(|c: char| !c.is_alphanumeric() && c != '_')
3984        .any(|word| names.contains(word))
3985}
3986
3987fn content_contains_identifier(content: &str, identifier: &str) -> bool {
3988    content
3989        .split(|c: char| !c.is_alphanumeric() && c != '_')
3990        .any(|word| word == identifier)
3991}
3992
3993const IMPORT_SCAN_PREFIX_LINES: usize = 80;
3994
3995fn read_import_scan_prefix(path: &Path) -> Option<String> {
3996    let file = std::fs::File::open(path).ok()?;
3997    let mut content = String::new();
3998    for line in std::io::BufReader::new(file)
3999        .lines()
4000        .take(IMPORT_SCAN_PREFIX_LINES)
4001    {
4002        content.push_str(&line.ok()?);
4003        content.push('\n');
4004    }
4005    Some(content)
4006}
4007
4008fn content_import_tokens_for_file(
4009    importing_file_path: &str,
4010    content: &str,
4011    candidate_file_path: &str,
4012) -> Vec<String> {
4013    let mut tokens = Vec::new();
4014
4015    if importing_file_path.ends_with(".py") {
4016        for line in content.lines() {
4017            let trimmed = line.split('#').next().unwrap_or("").trim();
4018            if let Some(rest) = trimmed.strip_prefix("from ") {
4019                let Some(import_pos) = rest.find(" import ") else {
4020                    continue;
4021                };
4022                let source_path = rest[..import_pos].trim();
4023                if !import_source_matches_file(
4024                    importing_file_path,
4025                    source_path,
4026                    &[".py"],
4027                    candidate_file_path,
4028                ) {
4029                    continue;
4030                }
4031
4032                let names = rest[import_pos + " import ".len()..].trim();
4033                for import_part in names.split(',') {
4034                    let import_part = import_part
4035                        .trim()
4036                        .trim_matches(|c: char| c == '(' || c == ')' || c == ',');
4037                    if import_part.is_empty() {
4038                        continue;
4039                    }
4040                    let (original, local) = split_import_alias(import_part);
4041                    push_import_token(&mut tokens, original);
4042                    push_import_token(&mut tokens, local);
4043                }
4044            } else if let Some(rest) = trimmed.strip_prefix("import ") {
4045                for import_part in rest.split(',') {
4046                    let import_part = import_part.trim();
4047                    let (source_path, alias) = split_import_alias(import_part);
4048                    let source_path = source_path.split_whitespace().next().unwrap_or("").trim();
4049                    if source_path.is_empty()
4050                        || !import_source_matches_file(
4051                            importing_file_path,
4052                            source_path,
4053                            &[".py"],
4054                            candidate_file_path,
4055                        )
4056                    {
4057                        continue;
4058                    }
4059
4060                    let default_local = source_path.split('.').next().unwrap_or(source_path);
4061                    push_import_token(&mut tokens, alias);
4062                    push_import_token(&mut tokens, default_local);
4063                }
4064            }
4065        }
4066    }
4067
4068    if importing_file_path.ends_with(".js")
4069        || importing_file_path.ends_with(".ts")
4070        || importing_file_path.ends_with(".jsx")
4071        || importing_file_path.ends_with(".tsx")
4072    {
4073        static JS_NAMED_IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4074            Regex::new(
4075                r#"import\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#,
4076            )
4077            .unwrap()
4078        });
4079        static JS_NAMESPACE_IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4080            Regex::new(r#"import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]"#)
4081                .unwrap()
4082        });
4083        static JS_DEFAULT_IMPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4084            Regex::new(
4085                r#"import\s+(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s*,\s*\{[^}]*\})?\s*from\s*['"]([^'"]+)['"]"#,
4086            )
4087            .unwrap()
4088        });
4089        static JS_REEXPORT_RE: LazyLock<Regex> = LazyLock::new(|| {
4090            Regex::new(r#"export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#).unwrap()
4091        });
4092
4093        for cap in JS_NAMED_IMPORT_RE.captures_iter(content) {
4094            let names = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4095            let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4096            if !import_source_matches_file(
4097                importing_file_path,
4098                source_path,
4099                &[".ts", ".tsx", ".js", ".jsx"],
4100                candidate_file_path,
4101            ) {
4102                continue;
4103            }
4104            for name_part in names.split(',') {
4105                let name_part = name_part.trim();
4106                let name_part = name_part.strip_prefix("type ").unwrap_or(name_part);
4107                let (original, local) = split_import_alias(name_part);
4108                push_import_token(&mut tokens, original);
4109                push_import_token(&mut tokens, local);
4110            }
4111        }
4112
4113        for cap in JS_NAMESPACE_IMPORT_RE.captures_iter(content) {
4114            let alias = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4115            let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4116            if import_source_matches_file(
4117                importing_file_path,
4118                source_path,
4119                &[".ts", ".tsx", ".js", ".jsx"],
4120                candidate_file_path,
4121            ) {
4122                push_import_token(&mut tokens, alias);
4123            }
4124        }
4125
4126        for cap in JS_DEFAULT_IMPORT_RE.captures_iter(content) {
4127            let local = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4128            let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4129            if import_source_matches_file(
4130                importing_file_path,
4131                source_path,
4132                &[".ts", ".tsx", ".js", ".jsx"],
4133                candidate_file_path,
4134            ) {
4135                push_import_token(&mut tokens, local);
4136            }
4137        }
4138
4139        for cap in JS_REEXPORT_RE.captures_iter(content) {
4140            let names = cap.get(1).map(|m| m.as_str()).unwrap_or("");
4141            let source_path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
4142            if !import_source_matches_file(
4143                importing_file_path,
4144                source_path,
4145                &[".ts", ".tsx", ".js", ".jsx"],
4146                candidate_file_path,
4147            ) {
4148                continue;
4149            }
4150            for name_part in names.split(',') {
4151                let name_part = name_part.trim();
4152                let name_part = name_part.strip_prefix("type ").unwrap_or(name_part);
4153                let (original, local) = split_import_alias(name_part);
4154                push_import_token(&mut tokens, original);
4155                push_import_token(&mut tokens, local);
4156            }
4157        }
4158    }
4159
4160    tokens
4161}
4162
4163fn split_import_alias(import_part: &str) -> (&str, &str) {
4164    if let Some(pos) = import_part.find(" as ") {
4165        let original = import_part[..pos].trim();
4166        let local = import_part[pos + 4..].trim();
4167        (original, local)
4168    } else {
4169        let name = import_part.split_whitespace().next().unwrap_or("").trim();
4170        (name, name)
4171    }
4172}
4173
4174fn push_import_token(tokens: &mut Vec<String>, token: &str) {
4175    let token = token.trim();
4176    if !token.is_empty() && token != "*" {
4177        tokens.push(token.to_string());
4178    }
4179}
4180
4181/// Extract references using a pre-stripped version of the content.
4182/// Use this when you already have the stripped content (e.g. from dot-chain extraction)
4183/// to avoid stripping comments/strings twice.
4184fn extract_references_with_stripped<'a>(
4185    content: &'a str,
4186    own_name: &str,
4187    stripped: &str,
4188) -> Vec<&'a str> {
4189    extract_references_with_stripped_filtered(content, own_name, stripped, |_, _, _| true)
4190}
4191
4192fn extract_references_with_stripped_filtered<'a, F>(
4193    content: &'a str,
4194    own_name: &str,
4195    stripped: &str,
4196    mut include_token: F,
4197) -> Vec<&'a str>
4198where
4199    F: FnMut(usize, usize, usize) -> bool,
4200{
4201    let mut refs = Vec::new();
4202    let mut seen: HashSet<&str> = HashSet::new();
4203    let mut token_start: Option<usize> = None;
4204    let mut line = 1;
4205
4206    for (idx, ch) in content.char_indices() {
4207        if ch.is_alphanumeric() || ch == '_' {
4208            if token_start.is_none() {
4209                token_start = Some(idx);
4210            }
4211            continue;
4212        }
4213
4214        if let Some(start) = token_start.take() {
4215            maybe_push_reference_token(
4216                content,
4217                stripped,
4218                start,
4219                idx,
4220                line,
4221                own_name,
4222                &mut seen,
4223                &mut refs,
4224                &mut include_token,
4225            );
4226        }
4227
4228        if ch == '\n' {
4229            line += 1;
4230        }
4231    }
4232
4233    if let Some(start) = token_start {
4234        maybe_push_reference_token(
4235            content,
4236            stripped,
4237            start,
4238            content.len(),
4239            line,
4240            own_name,
4241            &mut seen,
4242            &mut refs,
4243            &mut include_token,
4244        );
4245    }
4246
4247    refs
4248}
4249
4250fn maybe_push_reference_token<'a, F>(
4251    content: &'a str,
4252    stripped: &str,
4253    start: usize,
4254    end: usize,
4255    line: usize,
4256    own_name: &str,
4257    seen: &mut HashSet<&'a str>,
4258    refs: &mut Vec<&'a str>,
4259    include_token: &mut F,
4260) where
4261    F: FnMut(usize, usize, usize) -> bool,
4262{
4263    let word = &content[start..end];
4264    if word.is_empty() || word == own_name {
4265        return;
4266    }
4267    if is_keyword(word) || word.len() < 2 {
4268        return;
4269    }
4270    // Skip very short lowercase identifiers (likely local vars: i, x, a, ok, id, etc.)
4271    if word.starts_with(|c: char| c.is_lowercase()) && word.len() < 3 {
4272        return;
4273    }
4274    if !word.starts_with(|c: char| c.is_alphabetic() || c == '_') {
4275        return;
4276    }
4277    // Skip common local variable names that create false graph edges
4278    if is_common_local_name(word) {
4279        return;
4280    }
4281    // Skip words that only appear in comments/strings
4282    if stripped.get(start..end) != Some(word) {
4283        return;
4284    }
4285    if !include_token(line, start, end) {
4286        return;
4287    }
4288    if seen.insert(word) {
4289        refs.push(word);
4290    }
4291}
4292
4293static COMMON_LOCAL_NAMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
4294    [
4295        "result", "results", "data", "config", "value", "values", "item", "items", "input",
4296        "output", "args", "opts", "name", "path", "file", "line", "count", "index", "temp", "prev",
4297        "next", "curr", "current", "node", "left", "right", "root", "head", "tail", "body", "text",
4298        "content", "source", "target", "entry", "error", "errors", "message", "response",
4299        "request", "context", "state", "props", "event", "handler", "callback", "options",
4300        "params", "query", "list", "base", "info", "meta", "kind", "mode", "flag", "size",
4301        "length", "width", "height", "start", "stop", "begin", "done", "found", "status", "code",
4302    ]
4303    .into_iter()
4304    .collect()
4305});
4306
4307/// Names that are overwhelmingly local variables, not entity references.
4308/// These create massive false-positive edges in the dependency graph.
4309fn is_common_local_name(word: &str) -> bool {
4310    COMMON_LOCAL_NAMES.contains(word)
4311}
4312
4313/// Infer reference type from context using word-boundary-aware matching.
4314fn infer_ref_type(content: &str, ref_name: &str) -> RefType {
4315    // Check if it's a function call: ref_name followed by ( with word boundary before.
4316    // Avoids format! allocation by finding ref_name and checking the next char.
4317    let bytes = content.as_bytes();
4318    let name_bytes = ref_name.as_bytes();
4319    let mut search_start = 0;
4320    while let Some(rel_pos) = content[search_start..].find(ref_name) {
4321        let pos = search_start + rel_pos;
4322        let after = pos + name_bytes.len();
4323        // Check next char is '('
4324        if after < bytes.len() && bytes[after] == b'(' {
4325            // Verify word boundary before
4326            let is_boundary = pos == 0 || {
4327                let prev = bytes[pos - 1];
4328                !prev.is_ascii_alphanumeric() && prev != b'_'
4329            };
4330            if is_boundary {
4331                return RefType::Calls;
4332            }
4333        }
4334        // Advance past pos to the next char boundary to avoid slicing inside a multi-byte UTF-8 char.
4335        search_start = pos + 1;
4336        while search_start < content.len() && !content.is_char_boundary(search_start) {
4337            search_start += 1;
4338        }
4339    }
4340
4341    // Check if it's in an import/use statement (line-level, not substring)
4342    for line in content.lines() {
4343        let trimmed = line.trim();
4344        if (trimmed.starts_with("import ")
4345            || trimmed.starts_with("use ")
4346            || trimmed.starts_with("from ")
4347            || trimmed.starts_with("require("))
4348            && trimmed.contains(ref_name)
4349        {
4350            return RefType::Imports;
4351        }
4352    }
4353
4354    // Default to type reference
4355    RefType::TypeRef
4356}
4357
4358static KEYWORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
4359    [
4360        // Common across languages
4361        "if",
4362        "else",
4363        "for",
4364        "while",
4365        "do",
4366        "switch",
4367        "case",
4368        "break",
4369        "continue",
4370        "return",
4371        "try",
4372        "catch",
4373        "finally",
4374        "throw",
4375        "new",
4376        "delete",
4377        "typeof",
4378        "instanceof",
4379        "in",
4380        "of",
4381        "true",
4382        "false",
4383        "null",
4384        "undefined",
4385        "void",
4386        "this",
4387        "super",
4388        "class",
4389        "extends",
4390        "implements",
4391        "interface",
4392        "enum",
4393        "const",
4394        "let",
4395        "var",
4396        "function",
4397        "async",
4398        "await",
4399        "yield",
4400        "import",
4401        "export",
4402        "default",
4403        "from",
4404        "as",
4405        "static",
4406        "public",
4407        "private",
4408        "protected",
4409        "abstract",
4410        "final",
4411        "override",
4412        // Rust
4413        "fn",
4414        "pub",
4415        "mod",
4416        "use",
4417        "struct",
4418        "impl",
4419        "trait",
4420        "where",
4421        "type",
4422        "self",
4423        "Self",
4424        "mut",
4425        "ref",
4426        "match",
4427        "loop",
4428        "move",
4429        "unsafe",
4430        "extern",
4431        "crate",
4432        "dyn",
4433        // Python
4434        "def",
4435        "elif",
4436        "except",
4437        "raise",
4438        "with",
4439        "pass",
4440        "lambda",
4441        "nonlocal",
4442        "global",
4443        "assert",
4444        "True",
4445        "False",
4446        "and",
4447        "or",
4448        "not",
4449        "is",
4450        // Go
4451        "func",
4452        "package",
4453        "range",
4454        "select",
4455        "chan",
4456        "go",
4457        "defer",
4458        "map",
4459        "make",
4460        "append",
4461        "len",
4462        "cap",
4463        // C/C++
4464        "auto",
4465        "register",
4466        "volatile",
4467        "sizeof",
4468        "typedef",
4469        "template",
4470        "typename",
4471        "namespace",
4472        "virtual",
4473        "inline",
4474        "constexpr",
4475        "nullptr",
4476        "noexcept",
4477        "explicit",
4478        "friend",
4479        "operator",
4480        "using",
4481        "cout",
4482        "endl",
4483        "cerr",
4484        "cin",
4485        "printf",
4486        "scanf",
4487        "malloc",
4488        "free",
4489        "NULL",
4490        "include",
4491        "ifdef",
4492        "ifndef",
4493        "endif",
4494        "define",
4495        "pragma",
4496        // Ruby
4497        "end",
4498        "then",
4499        "elsif",
4500        "unless",
4501        "until",
4502        "begin",
4503        "rescue",
4504        "ensure",
4505        "when",
4506        "require",
4507        "attr_accessor",
4508        "attr_reader",
4509        "attr_writer",
4510        "puts",
4511        "nil",
4512        "module",
4513        "defined",
4514        // C#
4515        "internal",
4516        "sealed",
4517        "readonly",
4518        "partial",
4519        "delegate",
4520        "event",
4521        "params",
4522        "out",
4523        "object",
4524        "decimal",
4525        "sbyte",
4526        "ushort",
4527        "uint",
4528        "ulong",
4529        "nint",
4530        "nuint",
4531        "dynamic",
4532        "get",
4533        "set",
4534        "value",
4535        "init",
4536        "record",
4537        // Types (primitives)
4538        "string",
4539        "number",
4540        "boolean",
4541        "int",
4542        "float",
4543        "double",
4544        "bool",
4545        "char",
4546        "byte",
4547        "i8",
4548        "i16",
4549        "i32",
4550        "i64",
4551        "u8",
4552        "u16",
4553        "u32",
4554        "u64",
4555        "f32",
4556        "f64",
4557        "usize",
4558        "isize",
4559        "str",
4560        "String",
4561        "Vec",
4562        "Option",
4563        "Result",
4564        "Box",
4565        "Arc",
4566        "Rc",
4567        "HashMap",
4568        "HashSet",
4569        "Some",
4570        "Ok",
4571        "Err",
4572    ]
4573    .into_iter()
4574    .collect()
4575});
4576
4577fn is_keyword(word: &str) -> bool {
4578    KEYWORDS.contains(word)
4579}
4580
4581#[cfg(test)]
4582mod tests {
4583    use super::*;
4584    use crate::git::types::{FileChange, FileStatus};
4585    use std::io::Write;
4586    use tempfile::TempDir;
4587
4588    fn create_test_repo() -> (TempDir, ParserRegistry) {
4589        let dir = TempDir::new().unwrap();
4590        let registry = crate::parser::plugins::create_default_registry();
4591        (dir, registry)
4592    }
4593
4594    fn write_file(dir: &Path, name: &str, content: &str) {
4595        let path = dir.join(name);
4596        if let Some(parent) = path.parent() {
4597            std::fs::create_dir_all(parent).unwrap();
4598        }
4599        let mut f = std::fs::File::create(path).unwrap();
4600        f.write_all(content.as_bytes()).unwrap();
4601    }
4602
4603    fn dependency_ids(graph: &EntityGraph, entity_id: &str) -> Vec<String> {
4604        let mut ids = graph
4605            .get_dependencies(entity_id)
4606            .into_iter()
4607            .map(|entity| entity.id.clone())
4608            .collect::<Vec<_>>();
4609        ids.sort();
4610        ids
4611    }
4612
4613    fn assert_direct_dependencies_match_full(
4614        root: &Path,
4615        files: &[String],
4616        registry: &ParserRegistry,
4617        entity_id: &str,
4618    ) {
4619        let (full_graph, _) = EntityGraph::build(root, files, registry);
4620        let expected = dependency_ids(&full_graph, entity_id);
4621        let (direct_graph, _) =
4622            EntityGraph::build_direct_dependencies(root, files, registry, |entity| {
4623                entity.id == entity_id
4624            });
4625        let actual = dependency_ids(&direct_graph, entity_id);
4626        assert_eq!(actual, expected);
4627    }
4628
4629    fn graph_json_payload(graph: &EntityGraph) -> serde_json::Value {
4630        let mut entities = graph.entities.values().collect::<Vec<_>>();
4631        entities.sort_by(|a, b| a.id.cmp(&b.id));
4632
4633        let mut edges = graph.edges.iter().collect::<Vec<_>>();
4634        edges.sort_by(|a, b| {
4635            a.from_entity
4636                .cmp(&b.from_entity)
4637                .then_with(|| a.to_entity.cmp(&b.to_entity))
4638                .then_with(|| {
4639                    test_ref_type_sort_key(&a.ref_type).cmp(&test_ref_type_sort_key(&b.ref_type))
4640                })
4641        });
4642
4643        serde_json::json!({
4644            "entities": entities,
4645            "edges": edges,
4646            "stats": {
4647                "entityCount": graph.entities.len(),
4648                "edgeCount": graph.edges.len(),
4649            },
4650        })
4651    }
4652
4653    fn test_ref_type_sort_key(ref_type: &RefType) -> u8 {
4654        match ref_type {
4655            RefType::Calls => 0,
4656            RefType::Imports => 1,
4657            RefType::TypeRef => 2,
4658        }
4659    }
4660
4661    fn deep_typescript(depth: usize) -> String {
4662        let mut content = String::from("class L0 {\n");
4663        for i in 1..depth {
4664            content.push_str(&"  ".repeat(i));
4665            content.push_str(&format!("L{i} = class {{\n"));
4666        }
4667        content.push_str(&"  ".repeat(depth));
4668        content.push_str("method() { return 1; }\n");
4669        for i in (1..depth).rev() {
4670            content.push_str(&"  ".repeat(i));
4671            content.push_str("};\n");
4672        }
4673        content.push_str("}\n");
4674        content
4675    }
4676
4677    #[test]
4678    fn test_file_reference_index_matches_reference_helpers() {
4679        let content = "\
4680import { Foo } from './foo';
4681class Runner {
4682    run() { return this.validate(Foo); }
4683    validate(input) { return input; }
4684}
4685";
4686        let index = FileReferenceIndex::from_content(content);
4687        let refs = index.refs_with_types_in_ranges(&[(1, 5)], "run");
4688        assert!(refs.iter().any(|(word, _)| *word == "Foo"));
4689        assert!(refs.iter().any(|(word, _)| *word == "Runner"));
4690        assert!(!refs.iter().any(|(word, _)| *word == "input"));
4691
4692        let dot_chains = index.dot_chains_in_ranges(&[(1, 5)]);
4693        assert!(dot_chains.contains(&("this", "validate")));
4694        assert!(refs
4695            .iter()
4696            .any(|(word, ref_type)| *word == "Foo" && *ref_type == RefType::Imports));
4697        assert!(refs
4698            .iter()
4699            .any(|(word, ref_type)| *word == "validate" && *ref_type == RefType::Calls));
4700    }
4701
4702    #[test]
4703    fn test_js_ts_import_token_scan_matches_supported_import_forms() {
4704        let content = "\
4705import type { X as TypeX } from './stale';
4706import DefaultThing, { X as Y, Z } from './stale';
4707import * as ns$ from './stale';
4708export { default as PublicDefault, X as PublicX } from './stale';
4709";
4710
4711        let mut tokens = content_import_tokens_for_file("consumer.ts", content, "stale.ts");
4712        tokens.sort_unstable();
4713        tokens.dedup();
4714
4715        for expected in [
4716            "X",
4717            "TypeX",
4718            "DefaultThing",
4719            "Y",
4720            "Z",
4721            "ns$",
4722            "default",
4723            "PublicDefault",
4724            "PublicX",
4725        ] {
4726            assert!(
4727                tokens.iter().any(|token| token == expected),
4728                "missing token {expected}; tokens: {tokens:?}"
4729            );
4730        }
4731    }
4732
4733    #[test]
4734    fn test_direct_reference_ranges_skip_nested_child_entities() {
4735        let parent = SemanticEntity {
4736            id: "parent".to_string(),
4737            file_path: "sample.ts".to_string(),
4738            entity_type: "function".to_string(),
4739            name: "outer".to_string(),
4740            parent_id: None,
4741            content: String::new(),
4742            content_hash: String::new(),
4743            structural_hash: None,
4744            start_line: 1,
4745            end_line: 7,
4746            metadata: None,
4747        };
4748        let mut child_line_ranges = HashMap::new();
4749        child_line_ranges.insert("parent".to_string(), vec![(3, 5)]);
4750
4751        let ranges = direct_reference_line_ranges(&parent, parent.end_line, &child_line_ranges);
4752        assert_eq!(ranges, vec![(1, 2), (6, 7)]);
4753
4754        let content = "\
4755function outer() {
4756  setup();
4757  function inner() {
4758    nested();
4759  }
4760  finish();
4761}
4762";
4763        let index = FileReferenceIndex::from_content(content);
4764        let refs = index.refs_with_types_in_ranges(&ranges, "outer");
4765
4766        assert!(refs.iter().any(|(word, _)| *word == "setup"));
4767        assert!(refs.iter().any(|(word, _)| *word == "finish"));
4768        assert!(!refs.iter().any(|(word, _)| *word == "nested"));
4769    }
4770
4771    #[test]
4772    fn test_deep_nested_typescript_graph_builds() {
4773        let (dir, registry) = create_test_repo();
4774        let root = dir.path();
4775
4776        let depth = 160;
4777        write_file(root, "deep.ts", &deep_typescript(depth));
4778
4779        let (graph, entities) = EntityGraph::build(root, &["deep.ts".into()], &registry);
4780
4781        assert!(graph.entities.contains_key("deep.ts::class::L0"));
4782        assert!(entities.iter().any(|e| e.name == "method"));
4783        assert_eq!(entities.len(), depth + 1);
4784    }
4785
4786    #[test]
4787    fn test_chunked_scope_resolution_keeps_cross_chunk_import_edges() {
4788        let (dir, registry) = create_test_repo();
4789        let root = dir.path();
4790
4791        let mut files = Vec::new();
4792        for index in 0..10 {
4793            let file_name = format!("file_{index}.ts");
4794            let content = if index == 0 {
4795                "export function target() { return 1; }\n".to_string()
4796            } else if index == 9 {
4797                "import { target } from './file_0';\nexport function caller() { return target(); }\n"
4798                    .to_string()
4799            } else {
4800                format!("export function filler_{index}() {{ return {index}; }}\n")
4801            };
4802            write_file(root, &file_name, &content);
4803            files.push(file_name);
4804        }
4805
4806        let (graph, _) = EntityGraph::build(root, &files, &registry);
4807        let caller_id = graph
4808            .entities
4809            .iter()
4810            .find(|(_, entity)| entity.name == "caller")
4811            .map(|(id, _)| id.clone())
4812            .expect("caller entity should exist");
4813        let deps = graph.get_dependencies(&caller_id);
4814
4815        assert!(
4816            deps.iter().any(|dep| dep.name == "target"),
4817            "caller should resolve imported target across scope chunks. Deps: {:?}",
4818            deps.iter().map(|dep| &dep.name).collect::<Vec<_>>()
4819        );
4820    }
4821
4822    #[test]
4823    fn test_ts_class_extends_type_ref_survives_scope_fallback_bound() {
4824        let (dir, registry) = create_test_repo();
4825        let root = dir.path();
4826
4827        write_file(
4828            root,
4829            "types.ts",
4830            "\
4831class Base {}
4832class Child extends Base {
4833    run() { return 1; }
4834}
4835",
4836        );
4837
4838        let (graph, _) = EntityGraph::build(root, &["types.ts".into()], &registry);
4839
4840        assert!(
4841            graph.edges.iter().any(|edge| {
4842                edge.from_entity.contains("Child")
4843                    && graph
4844                        .entities
4845                        .get(&edge.to_entity)
4846                        .map_or(false, |e| e.name == "Base")
4847            }),
4848            "Child should keep a type-ref edge to Base. Edges: {:?}",
4849            graph.edges
4850        );
4851    }
4852
4853    #[test]
4854    fn test_multiline_block_comment_preserves_reference_line_index() {
4855        let (dir, registry) = create_test_repo();
4856        let root = dir.path();
4857
4858        write_file(
4859            root,
4860            "calls.c",
4861            "\
4862/*
4863 multiline
4864 comment
4865*/
4866int helper() { return 1; }
4867int caller() { return helper(); }
4868",
4869        );
4870
4871        let (graph, _) = EntityGraph::build(root, &["calls.c".into()], &registry);
4872
4873        let caller_id = graph
4874            .entities
4875            .keys()
4876            .find(|id| id.contains("caller"))
4877            .expect("caller entity should exist");
4878        let deps = graph.get_dependencies(caller_id);
4879        assert!(
4880            deps.iter().any(|dep| dep.name == "helper"),
4881            "caller should depend on helper after a multiline block comment. Deps: {:?}",
4882            deps.iter().map(|d| &d.name).collect::<Vec<_>>()
4883        );
4884    }
4885
4886    #[test]
4887    fn test_strip_comments_and_strings_preserves_newlines_and_utf8() {
4888        let content = "\
4889const value = \"é\\
4890still string\";
4891const done = call();
4892/* unterminated block
4893comment with Helper
4894";
4895
4896        let stripped = strip_comments_and_strings(content);
4897        let newline_count = |text: &str| text.bytes().filter(|byte| *byte == b'\n').count();
4898
4899        assert_eq!(newline_count(&stripped), newline_count(content));
4900        assert!(stripped.contains("const done = call();"));
4901        assert!(!stripped.contains("still string"));
4902        assert!(!stripped.contains("Helper"));
4903
4904        let trailing_escape = strip_comments_and_strings("const value = \"unterminated\\");
4905        assert_eq!(newline_count(&trailing_escape), 0);
4906
4907        let triple = strip_comments_and_strings("'''doc\nwith Helper");
4908        assert_eq!(newline_count(&triple), 1);
4909        assert!(!triple.contains("Helper"));
4910    }
4911
4912    #[test]
4913    fn test_incremental_add_file() {
4914        let (dir, registry) = create_test_repo();
4915        let root = dir.path();
4916
4917        // Start with one file
4918        write_file(root, "a.ts", "export function foo() { return bar(); }\n");
4919        write_file(root, "b.ts", "export function bar() { return 1; }\n");
4920
4921        let (mut graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
4922        assert_eq!(graph.entities.len(), 2);
4923
4924        // Add a new file
4925        write_file(root, "c.ts", "export function baz() { return foo(); }\n");
4926        graph.update_from_changes(
4927            &[FileChange {
4928                file_path: "c.ts".into(),
4929                status: FileStatus::Added,
4930                old_file_path: None,
4931                before_content: None,
4932                after_content: None, // will read from disk
4933            }],
4934            root,
4935            &registry,
4936        );
4937
4938        assert_eq!(graph.entities.len(), 3);
4939        assert!(graph.entities.contains_key("c.ts::function::baz"));
4940        // baz references foo
4941        let baz_deps = graph.get_dependencies("c.ts::function::baz");
4942        assert!(
4943            baz_deps.iter().any(|d| d.name == "foo"),
4944            "baz should depend on foo. Deps: {:?}",
4945            baz_deps.iter().map(|d| &d.name).collect::<Vec<_>>()
4946        );
4947    }
4948
4949    #[test]
4950    fn test_incremental_delete_file() {
4951        let (dir, registry) = create_test_repo();
4952        let root = dir.path();
4953
4954        write_file(root, "a.ts", "export function foo() { return bar(); }\n");
4955        write_file(root, "b.ts", "export function bar() { return 1; }\n");
4956
4957        let (mut graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
4958        assert_eq!(graph.entities.len(), 2);
4959
4960        // Delete b.ts
4961        graph.update_from_changes(
4962            &[FileChange {
4963                file_path: "b.ts".into(),
4964                status: FileStatus::Deleted,
4965                old_file_path: None,
4966                before_content: None,
4967                after_content: None,
4968            }],
4969            root,
4970            &registry,
4971        );
4972
4973        assert_eq!(graph.entities.len(), 1);
4974        assert!(!graph.entities.contains_key("b.ts::function::bar"));
4975        // foo's dependency on bar should be pruned
4976        let foo_deps = graph.get_dependencies("a.ts::function::foo");
4977        assert!(
4978            foo_deps.is_empty(),
4979            "foo's deps should be empty after bar deleted. Deps: {:?}",
4980            foo_deps.iter().map(|d| &d.name).collect::<Vec<_>>()
4981        );
4982    }
4983
4984    #[test]
4985    fn test_incremental_modify_file() {
4986        let (dir, registry) = create_test_repo();
4987        let root = dir.path();
4988
4989        write_file(root, "a.ts", "export function foo() { return bar(); }\n");
4990        write_file(
4991            root,
4992            "b.ts",
4993            "export function bar() { return 1; }\nexport function baz() { return 2; }\n",
4994        );
4995
4996        let (mut graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
4997        assert_eq!(graph.entities.len(), 3);
4998
4999        // Modify a.ts to call baz instead of bar
5000        write_file(root, "a.ts", "export function foo() { return baz(); }\n");
5001        graph.update_from_changes(
5002            &[FileChange {
5003                file_path: "a.ts".into(),
5004                status: FileStatus::Modified,
5005                old_file_path: None,
5006                before_content: None,
5007                after_content: None,
5008            }],
5009            root,
5010            &registry,
5011        );
5012
5013        assert_eq!(graph.entities.len(), 3);
5014        // foo should now depend on baz, not bar
5015        let foo_deps = graph.get_dependencies("a.ts::function::foo");
5016        let dep_names: Vec<&str> = foo_deps.iter().map(|d| d.name.as_str()).collect();
5017        assert!(
5018            dep_names.contains(&"baz"),
5019            "foo should depend on baz after modification. Deps: {:?}",
5020            dep_names
5021        );
5022        assert!(
5023            !dep_names.contains(&"bar"),
5024            "foo should no longer depend on bar. Deps: {:?}",
5025            dep_names
5026        );
5027    }
5028
5029    #[test]
5030    fn test_incremental_stale_target_file_re_resolves_clean_caller() {
5031        let (dir, registry) = create_test_repo();
5032        let root = dir.path();
5033
5034        write_file(root, "a.py", "def use_it():\n    return helper()\n");
5035        write_file(root, "b.py", "def helper():\n    return 1\n");
5036
5037        let (cached_graph, cached_entities) =
5038            EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
5039        assert!(
5040            cached_graph
5041                .get_dependents("b.py::function::helper")
5042                .iter()
5043                .any(|entity| entity.id == "a.py::function::use_it"),
5044            "initial graph should include use_it -> helper"
5045        );
5046
5047        write_file(
5048            root,
5049            "b.py",
5050            "def helper():\n    return 1\n\n\ndef unrelated():\n    return 42\n",
5051        );
5052
5053        let cached_clean_entities = cached_entities
5054            .iter()
5055            .filter(|entity| entity.file_path != "b.py")
5056            .cloned()
5057            .collect();
5058        let cached_stale_entities = cached_entities
5059            .into_iter()
5060            .filter(|entity| entity.file_path == "b.py")
5061            .collect();
5062
5063        let (graph, _) = EntityGraph::build_incremental(
5064            root,
5065            &["b.py".into()],
5066            &["a.py".into(), "b.py".into()],
5067            cached_clean_entities,
5068            cached_graph.edges,
5069            cached_stale_entities,
5070            &registry,
5071        );
5072        let (fresh_graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
5073
5074        let mut helper_dependents = graph
5075            .get_dependents("b.py::function::helper")
5076            .iter()
5077            .map(|entity| entity.id.as_str())
5078            .collect::<Vec<_>>();
5079        helper_dependents.sort_unstable();
5080        let mut fresh_dependents = fresh_graph
5081            .get_dependents("b.py::function::helper")
5082            .iter()
5083            .map(|entity| entity.id.as_str())
5084            .collect::<Vec<_>>();
5085        fresh_dependents.sort_unstable();
5086        assert_eq!(
5087            helper_dependents, fresh_dependents,
5088            "incremental graph should match fresh resolution"
5089        );
5090        assert!(
5091            helper_dependents
5092                .iter()
5093                .any(|entity_id| *entity_id == "a.py::function::use_it"),
5094            "clean caller should still depend on content-clean helper. Dependents: {:?}",
5095            helper_dependents
5096        );
5097    }
5098
5099    #[test]
5100    fn test_incremental_added_stale_target_re_resolves_clean_reference() {
5101        let (dir, registry) = create_test_repo();
5102        let root = dir.path();
5103
5104        write_file(root, "a.py", "def use_it():\n    return helper()\n");
5105        write_file(root, "b.py", "def other():\n    return 1\n");
5106
5107        let (cached_graph, cached_entities) =
5108            EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
5109        assert!(
5110            !cached_graph
5111                .get_dependencies("a.py::function::use_it")
5112                .iter()
5113                .any(|entity| entity.name == "helper"),
5114            "initial graph should not resolve helper"
5115        );
5116
5117        write_file(
5118            root,
5119            "b.py",
5120            "def other():\n    return 1\n\n\ndef helper():\n    return 42\n",
5121        );
5122
5123        let cached_clean_entities = cached_entities
5124            .iter()
5125            .filter(|entity| entity.file_path != "b.py")
5126            .cloned()
5127            .collect();
5128        let cached_stale_entities = cached_entities
5129            .into_iter()
5130            .filter(|entity| entity.file_path == "b.py")
5131            .collect();
5132
5133        let (incremental_graph, _) = EntityGraph::build_incremental(
5134            root,
5135            &["b.py".into()],
5136            &["a.py".into(), "b.py".into()],
5137            cached_clean_entities,
5138            cached_graph.edges,
5139            cached_stale_entities,
5140            &registry,
5141        );
5142        let (fresh_graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
5143
5144        let mut incremental_dependents = incremental_graph
5145            .get_dependents("b.py::function::helper")
5146            .iter()
5147            .map(|entity| entity.id.as_str())
5148            .collect::<Vec<_>>();
5149        incremental_dependents.sort_unstable();
5150        let mut fresh_dependents = fresh_graph
5151            .get_dependents("b.py::function::helper")
5152            .iter()
5153            .map(|entity| entity.id.as_str())
5154            .collect::<Vec<_>>();
5155        fresh_dependents.sort_unstable();
5156        assert_eq!(
5157            incremental_dependents, fresh_dependents,
5158            "incremental graph should match fresh resolution"
5159        );
5160        assert!(
5161            incremental_dependents
5162                .iter()
5163                .any(|entity_id| *entity_id == "a.py::function::use_it"),
5164            "clean caller should resolve to added helper. Dependents: {:?}",
5165            incremental_dependents
5166        );
5167    }
5168
5169    #[test]
5170    fn test_incremental_added_stale_target_re_resolves_aliased_clean_reference() {
5171        let (dir, registry) = create_test_repo();
5172        let root = dir.path();
5173
5174        write_file(
5175            root,
5176            "a.ts",
5177            "import { helper as h } from './b';\n\nexport function useIt() { return h(); }\n",
5178        );
5179        write_file(root, "b.ts", "export function other() { return 1; }\n");
5180
5181        let (cached_graph, cached_entities) =
5182            EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
5183        assert!(
5184            !cached_graph
5185                .get_dependencies("a.ts::function::useIt")
5186                .iter()
5187                .any(|entity| entity.name == "helper"),
5188            "initial graph should not resolve aliased helper"
5189        );
5190
5191        write_file(
5192            root,
5193            "b.ts",
5194            "export function other() { return 1; }\n\nexport function helper() { return 42; }\n",
5195        );
5196
5197        let cached_clean_entities = cached_entities
5198            .iter()
5199            .filter(|entity| entity.file_path != "b.ts")
5200            .cloned()
5201            .collect();
5202        let cached_stale_entities = cached_entities
5203            .into_iter()
5204            .filter(|entity| entity.file_path == "b.ts")
5205            .collect();
5206
5207        let (incremental_graph, _) = EntityGraph::build_incremental(
5208            root,
5209            &["b.ts".into()],
5210            &["a.ts".into(), "b.ts".into()],
5211            cached_clean_entities,
5212            cached_graph.edges,
5213            cached_stale_entities,
5214            &registry,
5215        );
5216        let (fresh_graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
5217
5218        let mut incremental_dependents = incremental_graph
5219            .get_dependents("b.ts::function::helper")
5220            .iter()
5221            .map(|entity| entity.id.as_str())
5222            .collect::<Vec<_>>();
5223        incremental_dependents.sort_unstable();
5224        let mut fresh_dependents = fresh_graph
5225            .get_dependents("b.ts::function::helper")
5226            .iter()
5227            .map(|entity| entity.id.as_str())
5228            .collect::<Vec<_>>();
5229        fresh_dependents.sort_unstable();
5230        assert_eq!(
5231            incremental_dependents, fresh_dependents,
5232            "incremental graph should match fresh alias resolution"
5233        );
5234        assert!(
5235            incremental_dependents
5236                .iter()
5237                .any(|entity_id| *entity_id == "a.ts::function::useIt"),
5238            "aliased clean caller should resolve to added helper. Dependents: {:?}",
5239            incremental_dependents
5240        );
5241    }
5242
5243    #[test]
5244    fn test_incremental_added_stale_target_re_resolves_python_alias() {
5245        let (dir, registry) = create_test_repo();
5246        let root = dir.path();
5247
5248        write_file(
5249            root,
5250            "a.py",
5251            "from b import helper as h\n\ndef use_it():\n    return h()\n",
5252        );
5253        write_file(root, "b.py", "def other():\n    return 1\n");
5254
5255        let (cached_graph, cached_entities) =
5256            EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
5257        assert!(
5258            !cached_graph
5259                .get_dependencies("a.py::function::use_it")
5260                .iter()
5261                .any(|entity| entity.name == "helper"),
5262            "initial graph should not resolve aliased helper"
5263        );
5264
5265        write_file(
5266            root,
5267            "b.py",
5268            "def other():\n    return 1\n\n\ndef helper():\n    return 42\n",
5269        );
5270
5271        let cached_clean_entities = cached_entities
5272            .iter()
5273            .filter(|entity| entity.file_path != "b.py")
5274            .cloned()
5275            .collect();
5276        let cached_stale_entities = cached_entities
5277            .into_iter()
5278            .filter(|entity| entity.file_path == "b.py")
5279            .collect();
5280
5281        let (incremental_graph, _) = EntityGraph::build_incremental(
5282            root,
5283            &["b.py".into()],
5284            &["a.py".into(), "b.py".into()],
5285            cached_clean_entities,
5286            cached_graph.edges,
5287            cached_stale_entities,
5288            &registry,
5289        );
5290        let (fresh_graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
5291
5292        let mut incremental_dependents = incremental_graph
5293            .get_dependents("b.py::function::helper")
5294            .iter()
5295            .map(|entity| entity.id.as_str())
5296            .collect::<Vec<_>>();
5297        incremental_dependents.sort_unstable();
5298        let mut fresh_dependents = fresh_graph
5299            .get_dependents("b.py::function::helper")
5300            .iter()
5301            .map(|entity| entity.id.as_str())
5302            .collect::<Vec<_>>();
5303        fresh_dependents.sort_unstable();
5304        assert_eq!(
5305            incremental_dependents, fresh_dependents,
5306            "incremental graph should match fresh Python alias resolution"
5307        );
5308        assert!(
5309            incremental_dependents
5310                .iter()
5311                .any(|entity_id| *entity_id == "a.py::function::use_it"),
5312            "aliased clean caller should resolve to added helper. Dependents: {:?}",
5313            incremental_dependents
5314        );
5315    }
5316
5317    #[test]
5318    fn test_incremental_added_stale_target_re_resolves_namespace_short_reference() {
5319        let (dir, registry) = create_test_repo();
5320        let root = dir.path();
5321
5322        write_file(
5323            root,
5324            "a.ts",
5325            "import * as b from './b';\n\nexport function useIt() { return b.go(); }\n",
5326        );
5327        write_file(root, "b.ts", "export function other() { return 1; }\n");
5328
5329        let (cached_graph, cached_entities) =
5330            EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
5331        assert!(
5332            !cached_graph
5333                .get_dependencies("a.ts::function::useIt")
5334                .iter()
5335                .any(|entity| entity.name == "go"),
5336            "initial graph should not resolve namespace go"
5337        );
5338
5339        write_file(
5340            root,
5341            "b.ts",
5342            "export function other() { return 1; }\n\nexport function go() { return 42; }\n",
5343        );
5344
5345        let cached_clean_entities = cached_entities
5346            .iter()
5347            .filter(|entity| entity.file_path != "b.ts")
5348            .cloned()
5349            .collect();
5350        let cached_stale_entities = cached_entities
5351            .into_iter()
5352            .filter(|entity| entity.file_path == "b.ts")
5353            .collect();
5354
5355        let (incremental_graph, _) = EntityGraph::build_incremental(
5356            root,
5357            &["b.ts".into()],
5358            &["a.ts".into(), "b.ts".into()],
5359            cached_clean_entities,
5360            cached_graph.edges,
5361            cached_stale_entities,
5362            &registry,
5363        );
5364        let (fresh_graph, _) = EntityGraph::build(root, &["a.ts".into(), "b.ts".into()], &registry);
5365
5366        let mut incremental_dependents = incremental_graph
5367            .get_dependents("b.ts::function::go")
5368            .iter()
5369            .map(|entity| entity.id.as_str())
5370            .collect::<Vec<_>>();
5371        incremental_dependents.sort_unstable();
5372        let mut fresh_dependents = fresh_graph
5373            .get_dependents("b.ts::function::go")
5374            .iter()
5375            .map(|entity| entity.id.as_str())
5376            .collect::<Vec<_>>();
5377        fresh_dependents.sort_unstable();
5378        assert_eq!(
5379            incremental_dependents, fresh_dependents,
5380            "incremental graph should match fresh namespace resolution"
5381        );
5382        assert!(
5383            incremental_dependents
5384                .iter()
5385                .any(|entity_id| *entity_id == "a.ts::function::useIt"),
5386            "namespace clean caller should resolve to added go. Dependents: {:?}",
5387            incremental_dependents
5388        );
5389    }
5390
5391    #[test]
5392    fn test_incremental_stale_default_re_export_re_resolves_clean_barrel() {
5393        let (dir, registry) = create_test_repo();
5394        let root = dir.path();
5395
5396        write_file(
5397            root,
5398            "a.ts",
5399            "export default function targetA() { return 1; }\n",
5400        );
5401        write_file(
5402            root,
5403            "b.ts",
5404            "export default function targetB() { return 2; }\n",
5405        );
5406        write_file(root, "stale.ts", "export { default } from './a';\n");
5407        write_file(
5408            root,
5409            "barrel.ts",
5410            "export { default as publicTarget } from './stale';\n",
5411        );
5412
5413        let all_files = vec![
5414            "a.ts".to_string(),
5415            "b.ts".to_string(),
5416            "stale.ts".to_string(),
5417            "barrel.ts".to_string(),
5418        ];
5419        let (cached_graph, cached_entities) = EntityGraph::build(root, &all_files, &registry);
5420        let initial_deps = cached_graph.get_dependencies("barrel.ts::export::publicTarget");
5421        assert!(
5422            initial_deps
5423                .iter()
5424                .any(|entity| entity.file_path == "a.ts" && entity.name == "targetA"),
5425            "initial barrel export should resolve through stale.ts to a.ts. Deps: {:?}",
5426            initial_deps
5427                .iter()
5428                .map(|entity| (&entity.file_path, &entity.name))
5429                .collect::<Vec<_>>()
5430        );
5431
5432        write_file(root, "stale.ts", "export { default } from './b';\n");
5433
5434        let cached_clean_entities = cached_entities
5435            .iter()
5436            .filter(|entity| entity.file_path != "stale.ts")
5437            .cloned()
5438            .collect();
5439        let cached_stale_entities = cached_entities
5440            .into_iter()
5441            .filter(|entity| entity.file_path == "stale.ts")
5442            .collect();
5443
5444        let (incremental_graph, _) = EntityGraph::build_incremental(
5445            root,
5446            &["stale.ts".into()],
5447            &all_files,
5448            cached_clean_entities,
5449            cached_graph.edges,
5450            cached_stale_entities,
5451            &registry,
5452        );
5453        let (fresh_graph, _) = EntityGraph::build(root, &all_files, &registry);
5454
5455        let mut incremental_deps = incremental_graph
5456            .get_dependencies("barrel.ts::export::publicTarget")
5457            .iter()
5458            .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
5459            .collect::<Vec<_>>();
5460        incremental_deps.sort_unstable();
5461        let mut fresh_deps = fresh_graph
5462            .get_dependencies("barrel.ts::export::publicTarget")
5463            .iter()
5464            .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
5465            .collect::<Vec<_>>();
5466        fresh_deps.sort_unstable();
5467        assert_eq!(
5468            incremental_deps, fresh_deps,
5469            "incremental graph should match fresh re-export retargeting"
5470        );
5471        assert!(
5472            incremental_deps
5473                .iter()
5474                .any(|(file_path, name)| *file_path == "b.ts" && *name == "targetB"),
5475            "clean barrel export should retarget to b.ts. Deps: {:?}",
5476            incremental_deps
5477        );
5478    }
5479
5480    #[test]
5481    fn test_incremental_import_candidates_re_resolve_clean_barrel() {
5482        let (dir, registry) = create_test_repo();
5483        let root = dir.path();
5484
5485        write_file(
5486            root,
5487            "a.ts",
5488            "export default function targetA() { return 1; }\n",
5489        );
5490        write_file(
5491            root,
5492            "b.ts",
5493            "export default function targetB() { return 2; }\n",
5494        );
5495        write_file(root, "stale.ts", "export { default } from './a';\n");
5496        write_file(
5497            root,
5498            "barrel.ts",
5499            "export { default as publicTarget } from './stale';\n",
5500        );
5501
5502        let all_files = vec![
5503            "a.ts".to_string(),
5504            "b.ts".to_string(),
5505            "stale.ts".to_string(),
5506            "barrel.ts".to_string(),
5507        ];
5508        let (cached_graph, cached_entities) = EntityGraph::build(root, &all_files, &registry);
5509
5510        write_file(root, "stale.ts", "export { default } from './b';\n");
5511
5512        let cached_clean_entities = cached_entities
5513            .iter()
5514            .filter(|entity| entity.file_path != "stale.ts")
5515            .cloned()
5516            .collect();
5517        let cached_stale_entities = cached_entities
5518            .into_iter()
5519            .filter(|entity| entity.file_path == "stale.ts")
5520            .collect();
5521        let cached_importing_stale_files = vec!["barrel.ts".to_string()];
5522
5523        let (incremental_graph, _, _) =
5524            EntityGraph::build_incremental_with_metadata_and_import_candidates(
5525                root,
5526                &["stale.ts".into()],
5527                &all_files,
5528                cached_clean_entities,
5529                cached_graph.edges,
5530                cached_stale_entities,
5531                Some(&cached_importing_stale_files),
5532                &registry,
5533            );
5534        let (fresh_graph, _) = EntityGraph::build(root, &all_files, &registry);
5535
5536        let mut incremental_deps = incremental_graph
5537            .get_dependencies("barrel.ts::export::publicTarget")
5538            .iter()
5539            .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
5540            .collect::<Vec<_>>();
5541        incremental_deps.sort_unstable();
5542        let mut fresh_deps = fresh_graph
5543            .get_dependencies("barrel.ts::export::publicTarget")
5544            .iter()
5545            .map(|entity| (entity.file_path.as_str(), entity.name.as_str()))
5546            .collect::<Vec<_>>();
5547        fresh_deps.sort_unstable();
5548
5549        assert_eq!(incremental_deps, fresh_deps);
5550        assert!(
5551            incremental_deps
5552                .iter()
5553                .any(|(file_path, name)| *file_path == "b.ts" && *name == "targetB"),
5554            "candidate-aware incremental rebuild should retarget the clean barrel export. Deps: {:?}",
5555            incremental_deps
5556        );
5557    }
5558
5559    #[test]
5560    fn test_incremental_swift_overload_uses_clean_callee_signatures() {
5561        let (dir, registry) = create_test_repo();
5562        let root = dir.path();
5563
5564        write_file(
5565            root,
5566            "Callee.swift",
5567            r#"func load(id: Int) -> String { return "id" }
5568
5569func load(name: String) -> String { return "name" }
5570"#,
5571        );
5572        write_file(
5573            root,
5574            "Caller.swift",
5575            r#"func call() -> String { return load(id: 1) }
5576"#,
5577        );
5578
5579        let file_paths = vec!["Caller.swift".to_string(), "Callee.swift".to_string()];
5580        let (initial_graph, initial_entities) = EntityGraph::build(root, &file_paths, &registry);
5581
5582        write_file(
5583            root,
5584            "Caller.swift",
5585            r#"func call() -> String { return load(name: "x") }
5586"#,
5587        );
5588
5589        let stale_file_cached_entities: Vec<SemanticEntity> = initial_entities
5590            .iter()
5591            .filter(|entity| entity.file_path == "Caller.swift")
5592            .cloned()
5593            .collect();
5594        let cached_entities: Vec<SemanticEntity> = initial_entities
5595            .into_iter()
5596            .filter(|entity| entity.file_path != "Caller.swift")
5597            .collect();
5598
5599        let (graph, _) = EntityGraph::build_incremental(
5600            root,
5601            &["Caller.swift".to_string()],
5602            &file_paths,
5603            cached_entities,
5604            initial_graph.edges,
5605            stale_file_cached_entities,
5606            &registry,
5607        );
5608
5609        let has_edge = |from: &str, to: &str| {
5610            graph.edges.iter().any(|edge| {
5611                edge.from_entity == from && edge.to_entity == to && edge.ref_type == RefType::Calls
5612            })
5613        };
5614
5615        assert!(
5616            has_edge(
5617                "Caller.swift::function::call",
5618                "Callee.swift::function::load@L3"
5619            ),
5620            "incremental caller should resolve load(name:) using clean callee signatures"
5621        );
5622        assert!(
5623            !has_edge(
5624                "Caller.swift::function::call",
5625                "Callee.swift::function::load@L1"
5626            ),
5627            "incremental caller should not fall back to load(id:)"
5628        );
5629    }
5630
5631    #[test]
5632    fn test_incremental_with_content() {
5633        let (dir, registry) = create_test_repo();
5634        let root = dir.path();
5635
5636        write_file(root, "a.ts", "export function foo() { return 1; }\n");
5637        let (mut graph, _) = EntityGraph::build(root, &["a.ts".into()], &registry);
5638        assert_eq!(graph.entities.len(), 1);
5639
5640        // Add file with content provided directly (no disk read needed)
5641        graph.update_from_changes(
5642            &[FileChange {
5643                file_path: "b.ts".into(),
5644                status: FileStatus::Added,
5645                old_file_path: None,
5646                before_content: None,
5647                after_content: Some("export function bar() { return foo(); }\n".into()),
5648            }],
5649            root,
5650            &registry,
5651        );
5652
5653        assert_eq!(graph.entities.len(), 2);
5654        let bar_deps = graph.get_dependencies("b.ts::function::bar");
5655        assert!(bar_deps.iter().any(|d| d.name == "foo"));
5656    }
5657
5658    #[cfg(feature = "lang-go")]
5659    #[test]
5660    fn test_go_method_parent_resolves_across_files_in_graph() {
5661        let (dir, registry) = create_test_repo();
5662        let root = dir.path();
5663
5664        write_file(root, "models.go", "package demo\n\ntype Service struct{}\n");
5665        write_file(
5666            root,
5667            "methods.go",
5668            "package demo\n\nfunc (s *Service) Run() {}\n",
5669        );
5670
5671        let (graph, entities) =
5672            EntityGraph::build(root, &["models.go".into(), "methods.go".into()], &registry);
5673        let service = graph
5674            .entities
5675            .get("models.go::type::Service")
5676            .expect("Service type should be in the graph");
5677        let run = entities
5678            .iter()
5679            .find(|e| e.name == "Run" && e.file_path == "methods.go")
5680            .expect("Run method should be extracted");
5681
5682        assert_eq!(run.parent_id.as_deref(), Some(service.id.as_str()));
5683        assert!(graph.entities.contains_key("models.go::type::Service::Run"));
5684    }
5685
5686    #[cfg(feature = "lang-go")]
5687    #[test]
5688    fn test_incremental_go_parent_repair_handles_clean_cached_method() {
5689        let (dir, registry) = create_test_repo();
5690        let root = dir.path();
5691        let models = "package demo\n\ntype Service struct{}\n";
5692        let methods = "package demo\n\nfunc (s *Service) Run() {}\n";
5693
5694        write_file(root, "models.go", models);
5695        write_file(root, "methods.go", methods);
5696
5697        let cached_entities = registry.extract_entities("methods.go", methods);
5698        let cached_run = cached_entities
5699            .iter()
5700            .find(|e| e.name == "Run")
5701            .expect("cached Run method should be extracted");
5702        assert_eq!(
5703            cached_run.parent_id.as_deref(),
5704            Some("methods.go::type::Service")
5705        );
5706
5707        let stale_file_cached_entities = registry.extract_entities("models.go", models);
5708        let (graph, entities, metadata) = EntityGraph::build_incremental_with_metadata(
5709            root,
5710            &["models.go".into()],
5711            &["models.go".into(), "methods.go".into()],
5712            cached_entities,
5713            vec![],
5714            stale_file_cached_entities,
5715            &registry,
5716        );
5717        let service = graph
5718            .entities
5719            .get("models.go::type::Service")
5720            .expect("Service type should be in the graph");
5721        let run = entities
5722            .iter()
5723            .find(|e| e.name == "Run" && e.file_path == "methods.go")
5724            .expect("Run method should be retained from clean cache");
5725
5726        assert_eq!(run.parent_id.as_deref(), Some(service.id.as_str()));
5727        assert!(graph.entities.contains_key("models.go::type::Service::Run"));
5728        assert!(!graph
5729            .entities
5730            .contains_key("methods.go::type::Service::Run"));
5731        assert!(metadata.repaired_clean_entity_ids);
5732    }
5733
5734    #[cfg(feature = "lang-go")]
5735    #[test]
5736    fn test_go_receiver_child_range_does_not_hide_parent_file_edges() {
5737        let (dir, registry) = create_test_repo();
5738        let root = dir.path();
5739
5740        write_file(
5741            root,
5742            "models.go",
5743            "package demo\n\
5744             type Dependency struct{}\n\
5745             type Service struct { Dependency }\n",
5746        );
5747        write_file(
5748            root,
5749            "methods.go",
5750            "package demo\n\n\
5751             func (s *Service) Run() {}\n",
5752        );
5753
5754        let (graph, _) =
5755            EntityGraph::build(root, &["models.go".into(), "methods.go".into()], &registry);
5756
5757        assert!(
5758            graph.edges.iter().any(|edge| {
5759                edge.from_entity == "models.go::type::Service"
5760                    && edge.to_entity == "models.go::type::Dependency"
5761            }),
5762            "Service should keep its Dependency edge. Edges: {:?}",
5763            graph.edges
5764        );
5765    }
5766
5767    #[cfg(feature = "lang-swift")]
5768    #[test]
5769    fn test_swift_extension_member_resolves_through_receiver_type() {
5770        let (dir, registry) = create_test_repo();
5771        let root = dir.path();
5772
5773        write_file(
5774            root,
5775            "Example.swift",
5776            r#"
5777struct Widget {
5778    let name: String
5779}
5780
5781extension Widget {
5782    func render() -> String { return name }
5783}
5784
5785func draw(widget: Widget) {
5786    print(widget.render())
5787}
5788"#,
5789        );
5790
5791        let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], &registry);
5792        let draw = graph
5793            .entities
5794            .values()
5795            .find(|entity| entity.name == "draw")
5796            .expect("draw function should be in the graph");
5797        let extension = graph
5798            .entities
5799            .values()
5800            .find(|entity| entity.entity_type == "extension")
5801            .expect("extension should be in the graph");
5802        assert_eq!(extension.name, "Widget");
5803        let render = graph
5804            .entities
5805            .values()
5806            .find(|entity| entity.name == "render")
5807            .expect("extension method should be in the graph");
5808        assert_eq!(render.parent_id.as_deref(), Some(extension.id.as_str()));
5809
5810        assert!(
5811            graph.edges.iter().any(|edge| {
5812                edge.from_entity == draw.id
5813                    && edge.to_entity == render.id
5814                    && edge.ref_type == RefType::Calls
5815            }),
5816            "draw should call Widget.render. Edges: {:?}",
5817            graph.edges
5818        );
5819
5820        let render_dependents = graph.get_dependents(&render.id);
5821        assert!(
5822            render_dependents.iter().any(|entity| entity.id == draw.id),
5823            "render should be impacted by draw. Dependents: {:?}",
5824            render_dependents
5825                .iter()
5826                .map(|entity| &entity.name)
5827                .collect::<Vec<_>>()
5828        );
5829    }
5830
5831    #[cfg(feature = "lang-swift")]
5832    #[test]
5833    fn test_swift_extension_member_resolves_without_prebuilt_lookup() {
5834        let (_dir, registry) = create_test_repo();
5835        let source = r#"
5836struct Widget {
5837    let name: String
5838}
5839
5840extension Widget {
5841    func render() -> String { return name }
5842}
5843
5844func draw(widget: Widget) {
5845    print(widget.render())
5846}
5847"#;
5848
5849        let all_entities = registry.extract_entities("Example.swift", source);
5850        let extension = all_entities
5851            .iter()
5852            .find(|entity| entity.entity_type == "extension")
5853            .expect("extension should be extracted");
5854        assert_eq!(extension.name, "Widget");
5855        let render = all_entities
5856            .iter()
5857            .find(|entity| entity.name == "render")
5858            .expect("extension method should be extracted");
5859        assert_eq!(render.parent_id.as_deref(), Some(extension.id.as_str()));
5860        let entity_map: HashMap<String, EntityInfo> = all_entities
5861            .iter()
5862            .map(|entity| {
5863                (
5864                    entity.id.clone(),
5865                    EntityInfo {
5866                        id: entity.id.clone(),
5867                        name: entity.name.clone(),
5868                        entity_type: entity.entity_type.clone(),
5869                        file_path: entity.file_path.clone(),
5870                        parent_id: entity.parent_id.clone(),
5871                        start_line: entity.start_line,
5872                        end_line: entity.end_line,
5873                    },
5874                )
5875            })
5876            .collect();
5877
5878        let result = scope_resolve::resolve_with_scopes(
5879            Path::new("."),
5880            &["Example.swift".into()],
5881            &all_entities,
5882            &entity_map,
5883            Some(vec![(
5884                "Example.swift".into(),
5885                source.into(),
5886                registry
5887                    .extract_entities_with_tree("Example.swift", source)
5888                    .and_then(|(_, tree)| tree)
5889                    .expect("Swift parser should produce a tree"),
5890            )]),
5891        );
5892        let draw = all_entities
5893            .iter()
5894            .find(|entity| entity.name == "draw")
5895            .expect("draw function should be extracted");
5896
5897        assert!(
5898            result.edges.iter().any(|(from, to, ref_type)| {
5899                from == &draw.id && to == &render.id && *ref_type == RefType::Calls
5900            }),
5901            "fallback scope resolver should resolve draw to Widget.render. Edges: {:?}",
5902            result.edges
5903        );
5904    }
5905
5906    #[test]
5907    fn test_extract_references() {
5908        let content = "function processData(input) {\n  const result = validateInput(input);\n  return transform(result);\n}";
5909        let refs = extract_references_from_content(content, "processData");
5910        assert!(refs.contains(&"validateInput"));
5911        assert!(refs.contains(&"transform"));
5912        assert!(!refs.contains(&"processData")); // self excluded
5913    }
5914
5915    #[test]
5916    fn test_container_does_not_inherit_child_call_edges() {
5917        let (dir, registry) = create_test_repo();
5918        let root = dir.path();
5919
5920        write_file(
5921            root,
5922            "app.ts",
5923            "export function helper() { return 1; }\n\
5924             export class Service {\n\
5925               method() { return helper(); }\n\
5926             }\n\
5927             export class InlineService { method() { return helper(); } }\n",
5928        );
5929
5930        let (graph, _) = EntityGraph::build(root, &["app.ts".into()], &registry);
5931        let helper_id = "app.ts::function::helper";
5932
5933        for (class_id, method_id) in [
5934            ("app.ts::class::Service", "app.ts::class::Service::method"),
5935            (
5936                "app.ts::class::InlineService",
5937                "app.ts::class::InlineService::method",
5938            ),
5939        ] {
5940            assert!(
5941                graph.edges.iter().any(|edge| {
5942                    edge.from_entity == method_id
5943                        && edge.to_entity == helper_id
5944                        && edge.ref_type == RefType::Calls
5945                }),
5946                "{method_id} should call helper. Edges: {:?}",
5947                graph.edges
5948            );
5949            assert!(
5950                !graph
5951                    .edges
5952                    .iter()
5953                    .any(|edge| edge.from_entity == class_id && edge.to_entity == helper_id),
5954                "{class_id} should not call helper. Edges: {:?}",
5955                graph.edges
5956            );
5957        }
5958    }
5959
5960    #[test]
5961    fn test_incremental_container_does_not_inherit_child_call_edges() {
5962        let (dir, registry) = create_test_repo();
5963        let root = dir.path();
5964
5965        write_file(
5966            root,
5967            "app.ts",
5968            "export function helper() { return 1; }\n\
5969             export class Service {\n\
5970               method() { return helper(); }\n\
5971             }\n",
5972        );
5973        let (initial_graph, initial_entities) =
5974            EntityGraph::build(root, &["app.ts".into()], &registry);
5975
5976        write_file(
5977            root,
5978            "app.ts",
5979            "export function helper() { return 2; }\n\
5980             export function extra() { return 3; }\n\
5981             export class Service {\n\
5982               method() { return helper(); }\n\
5983             }\n",
5984        );
5985
5986        let (graph, _) = EntityGraph::build_incremental(
5987            root,
5988            &["app.ts".into()],
5989            &["app.ts".into()],
5990            vec![],
5991            initial_graph.edges,
5992            initial_entities,
5993            &registry,
5994        );
5995
5996        let class_id = "app.ts::class::Service";
5997        let method_id = "app.ts::class::Service::method";
5998        let helper_id = "app.ts::function::helper";
5999        assert!(
6000            graph.edges.iter().any(|edge| {
6001                edge.from_entity == method_id
6002                    && edge.to_entity == helper_id
6003                    && edge.ref_type == RefType::Calls
6004            }),
6005            "{method_id} should call helper. Edges: {:?}",
6006            graph.edges
6007        );
6008        assert!(
6009            !graph
6010                .edges
6011                .iter()
6012                .any(|edge| edge.from_entity == class_id && edge.to_entity == helper_id),
6013            "{class_id} should not call helper. Edges: {:?}",
6014            graph.edges
6015        );
6016    }
6017
6018    #[test]
6019    fn test_same_line_container_and_child_refs_use_byte_spans() {
6020        let (dir, registry) = create_test_repo();
6021        let root = dir.path();
6022
6023        write_file(
6024            root,
6025            "app.ts",
6026            "export function helper() { return 1; }\n\
6027             export function other() { return 2; }\n\
6028             export class Service { static { helper(); } method() { return other(); } }\n",
6029        );
6030
6031        let (graph, _) = EntityGraph::build(root, &["app.ts".into()], &registry);
6032        let class_id = "app.ts::class::Service";
6033        let method_id = "app.ts::class::Service::method";
6034        let helper_id = "app.ts::function::helper";
6035        let other_id = "app.ts::function::other";
6036
6037        assert!(
6038            graph.edges.iter().any(|edge| {
6039                edge.from_entity == class_id
6040                    && edge.to_entity == helper_id
6041                    && edge.ref_type == RefType::Calls
6042            }),
6043            "{class_id} should own the static-block helper call. Edges: {:?}",
6044            graph.edges
6045        );
6046        assert!(
6047            graph.edges.iter().any(|edge| {
6048                edge.from_entity == method_id
6049                    && edge.to_entity == other_id
6050                    && edge.ref_type == RefType::Calls
6051            }),
6052            "{method_id} should own the method-body other call. Edges: {:?}",
6053            graph.edges
6054        );
6055        assert!(
6056            !graph
6057                .edges
6058                .iter()
6059                .any(|edge| edge.from_entity == method_id && edge.to_entity == helper_id),
6060            "{method_id} should not inherit the static-block helper call. Edges: {:?}",
6061            graph.edges
6062        );
6063        assert!(
6064            !graph
6065                .edges
6066                .iter()
6067                .any(|edge| edge.from_entity == class_id && edge.to_entity == other_id),
6068            "{class_id} should not inherit the method-body other call. Edges: {:?}",
6069            graph.edges
6070        );
6071    }
6072
6073    #[test]
6074    fn test_extract_references_skips_keywords() {
6075        let content = "function foo() { if (true) { return false; } }";
6076        let refs = extract_references_from_content(content, "foo");
6077        assert!(!refs.contains(&"if"));
6078        assert!(!refs.contains(&"true"));
6079        assert!(!refs.contains(&"return"));
6080        assert!(!refs.contains(&"false"));
6081    }
6082
6083    #[test]
6084    fn test_infer_ref_type_call() {
6085        assert_eq!(
6086            infer_ref_type("validateInput(data)", "validateInput"),
6087            RefType::Calls,
6088        );
6089    }
6090
6091    #[test]
6092    fn test_infer_ref_type_type() {
6093        assert_eq!(
6094            infer_ref_type("let x: MyType = something", "MyType"),
6095            RefType::TypeRef,
6096        );
6097    }
6098
6099    #[test]
6100    fn test_infer_ref_type_multibyte_utf8() {
6101        // Ensure no panic when content contains multi-byte UTF-8 characters
6102        assert_eq!(infer_ref_type("let café = foo(x)", "foo"), RefType::Calls,);
6103        assert_eq!(
6104            infer_ref_type(
6105                "class HandicapfrPublicationFieldsEnum:\n    É = 1\n    bar()",
6106                "bar"
6107            ),
6108            RefType::Calls,
6109        );
6110        // No match should not panic either
6111        assert_eq!(
6112            infer_ref_type("// 日本語コメント\nlet x = 1", "missing"),
6113            RefType::TypeRef,
6114        );
6115    }
6116
6117    #[test]
6118    fn test_dot_chain_self_resolution() {
6119        let (dir, registry) = create_test_repo();
6120        let root = dir.path();
6121
6122        write_file(
6123            root,
6124            "service.py",
6125            "\
6126class MyService:
6127    def process(self):
6128        return self.validate()
6129
6130    def validate(self):
6131        return True
6132",
6133        );
6134
6135        let (graph, _) = EntityGraph::build(root, &["service.py".into()], &registry);
6136
6137        // process should have an edge to validate via self.validate()
6138        let process_id = graph
6139            .entities
6140            .keys()
6141            .find(|id| id.contains("process"))
6142            .expect("process entity should exist");
6143        let deps = graph.get_dependencies(process_id);
6144        assert!(
6145            deps.iter().any(|d| d.name == "validate"),
6146            "process should depend on validate via self.validate(). Deps: {:?}",
6147            deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6148        );
6149    }
6150
6151    #[test]
6152    fn test_dot_chain_this_resolution() {
6153        let (dir, registry) = create_test_repo();
6154        let root = dir.path();
6155
6156        write_file(
6157            root,
6158            "service.ts",
6159            "\
6160class UserService {
6161    process() {
6162        return this.validate();
6163    }
6164    validate() {
6165        return true;
6166    }
6167}
6168",
6169        );
6170
6171        let (graph, _) = EntityGraph::build(root, &["service.ts".into()], &registry);
6172
6173        let process_id = graph
6174            .entities
6175            .keys()
6176            .find(|id| id.contains("process"))
6177            .expect("process entity should exist");
6178        let deps = graph.get_dependencies(process_id);
6179        assert!(
6180            deps.iter().any(|d| d.name == "validate"),
6181            "process should depend on validate via this.validate(). Deps: {:?}",
6182            deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6183        );
6184    }
6185
6186    #[cfg(feature = "lang-swift")]
6187    #[test]
6188    fn test_swift_bare_instance_property_receiver_resolution() {
6189        let (dir, registry) = create_test_repo();
6190        let root = dir.path();
6191
6192        write_file(
6193            root,
6194            "Example.swift",
6195            "\
6196class Connection {
6197    func execute(query: String) {}
6198    func commit() {}
6199}
6200
6201class Transaction {
6202    let conn: Connection
6203    init(conn: Connection) { self.conn = conn }
6204
6205    func execute(query: String) {
6206        conn.execute(query: query)
6207    }
6208
6209    func commit() {
6210        conn.commit()
6211    }
6212}
6213",
6214        );
6215
6216        let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], &registry);
6217
6218        let transaction_execute_id = graph
6219            .entities
6220            .iter()
6221            .find(|(id, info)| info.name == "execute" && id.contains("Transaction"))
6222            .map(|(id, _)| id.clone())
6223            .expect("Transaction.execute entity should exist");
6224        let execute_deps = graph.get_dependencies(&transaction_execute_id);
6225        assert!(
6226            execute_deps.iter().any(|d| {
6227                d.name == "execute"
6228                    && d.parent_id
6229                        .as_deref()
6230                        .map_or(false, |parent| parent.contains("Connection"))
6231            }),
6232            "Transaction.execute should depend on Connection.execute. Deps: {:?}",
6233            execute_deps
6234                .iter()
6235                .map(|d| (&d.name, &d.parent_id))
6236                .collect::<Vec<_>>()
6237        );
6238
6239        let transaction_commit_id = graph
6240            .entities
6241            .iter()
6242            .find(|(id, info)| info.name == "commit" && id.contains("Transaction"))
6243            .map(|(id, _)| id.clone())
6244            .expect("Transaction.commit entity should exist");
6245        let commit_deps = graph.get_dependencies(&transaction_commit_id);
6246        assert!(
6247            commit_deps.iter().any(|d| {
6248                d.name == "commit"
6249                    && d.parent_id
6250                        .as_deref()
6251                        .map_or(false, |parent| parent.contains("Connection"))
6252            }),
6253            "Transaction.commit should depend on Connection.commit. Deps: {:?}",
6254            commit_deps
6255                .iter()
6256                .map(|d| (&d.name, &d.parent_id))
6257                .collect::<Vec<_>>()
6258        );
6259    }
6260
6261    #[cfg(feature = "lang-swift")]
6262    #[test]
6263    fn test_swift_static_method_does_not_resolve_instance_property_receiver() {
6264        let (dir, registry) = create_test_repo();
6265        let root = dir.path();
6266
6267        write_file(
6268            root,
6269            "Example.swift",
6270            "\
6271class Connection {
6272    func execute(query: String) {}
6273}
6274
6275class Transaction {
6276    let conn: Connection
6277
6278    static func run() {
6279        conn.execute(query: \"SELECT 1\")
6280    }
6281}
6282",
6283        );
6284
6285        let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], &registry);
6286
6287        let run_id = graph
6288            .entities
6289            .keys()
6290            .find(|id| id.contains("Transaction") && id.contains("run"))
6291            .expect("Transaction.run entity should exist");
6292        let deps = graph.get_dependencies(run_id);
6293        assert!(
6294            !deps.iter().any(|d| {
6295                d.name == "execute"
6296                    && d.parent_id
6297                        .as_deref()
6298                        .map_or(false, |parent| parent.contains("Connection"))
6299            }),
6300            "static Transaction.run should not depend on Connection.execute via instance property. Deps: {:?}",
6301            deps.iter()
6302                .map(|d| (&d.name, &d.parent_id))
6303                .collect::<Vec<_>>()
6304        );
6305    }
6306
6307    #[cfg(feature = "lang-swift")]
6308    #[test]
6309    fn test_swift_multi_binding_property_receivers_resolve() {
6310        let (dir, registry) = create_test_repo();
6311        let root = dir.path();
6312
6313        write_file(
6314            root,
6315            "Example.swift",
6316            "\
6317class PrimaryConnection {
6318    func execute(query: String) {}
6319}
6320
6321class BackupConnection {
6322    func flush() {}
6323}
6324
6325class Transaction {
6326    let conn: PrimaryConnection, backup: BackupConnection
6327
6328    func run(query: String) {
6329        conn.execute(query: query)
6330        backup.flush()
6331    }
6332}
6333",
6334        );
6335
6336        let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], &registry);
6337
6338        let run_id = graph
6339            .entities
6340            .keys()
6341            .find(|id| id.contains("Transaction") && id.contains("run"))
6342            .expect("Transaction.run entity should exist");
6343        let deps = graph.get_dependencies(run_id);
6344        assert!(
6345            deps.iter().any(|d| {
6346                d.name == "execute"
6347                    && d.parent_id
6348                        .as_deref()
6349                        .map_or(false, |parent| parent.contains("PrimaryConnection"))
6350            }),
6351            "conn.execute should resolve to PrimaryConnection.execute. Deps: {:?}",
6352            deps.iter()
6353                .map(|d| (&d.name, &d.parent_id))
6354                .collect::<Vec<_>>()
6355        );
6356        assert!(
6357            deps.iter().any(|d| {
6358                d.name == "flush"
6359                    && d.parent_id
6360                        .as_deref()
6361                        .map_or(false, |parent| parent.contains("BackupConnection"))
6362            }),
6363            "backup.flush should resolve to BackupConnection.flush. Deps: {:?}",
6364            deps.iter()
6365                .map(|d| (&d.name, &d.parent_id))
6366                .collect::<Vec<_>>()
6367        );
6368    }
6369
6370    #[cfg(feature = "lang-swift")]
6371    #[test]
6372    fn test_swift_nested_local_binding_shadows_instance_property_receiver() {
6373        let (dir, registry) = create_test_repo();
6374        let root = dir.path();
6375
6376        write_file(
6377            root,
6378            "Example.swift",
6379            "\
6380class Connection {
6381    func execute(query: String) {}
6382}
6383
6384class MockConnection {
6385    func execute(query: String) {}
6386}
6387
6388class Transaction {
6389    let conn: Connection
6390    init(conn: Connection) { self.conn = conn }
6391
6392    func execute(query: String, useMock: Bool) {
6393        if useMock {
6394            let conn = MockConnection()
6395            conn.execute(query: query)
6396        }
6397    }
6398}
6399",
6400        );
6401
6402        let (graph, _) = EntityGraph::build(root, &["Example.swift".into()], &registry);
6403
6404        let transaction_execute_id = graph
6405            .entities
6406            .iter()
6407            .find(|(id, info)| info.name == "execute" && id.contains("Transaction"))
6408            .map(|(id, _)| id.clone())
6409            .expect("Transaction.execute entity should exist");
6410        let deps = graph.get_dependencies(&transaction_execute_id);
6411        assert!(
6412            deps.iter().any(|d| {
6413                d.name == "execute"
6414                    && d.parent_id
6415                        .as_deref()
6416                        .map_or(false, |parent| parent.contains("MockConnection"))
6417            }),
6418            "nested local conn should resolve to MockConnection.execute. Deps: {:?}",
6419            deps.iter()
6420                .map(|d| (&d.name, &d.parent_id))
6421                .collect::<Vec<_>>()
6422        );
6423        assert!(
6424            !deps.iter().any(|d| {
6425                d.name == "execute"
6426                    && d.parent_id.as_deref().map_or(false, |parent| {
6427                        parent.contains("Connection") && !parent.contains("MockConnection")
6428                    })
6429            }),
6430            "nested local conn should shadow Transaction.conn. Deps: {:?}",
6431            deps.iter()
6432                .map(|d| (&d.name, &d.parent_id))
6433                .collect::<Vec<_>>()
6434        );
6435    }
6436
6437    #[test]
6438    fn test_typescript_bare_identifier_does_not_resolve_instance_property() {
6439        let (dir, registry) = create_test_repo();
6440        let root = dir.path();
6441
6442        write_file(
6443            root,
6444            "service.ts",
6445            "\
6446class Connection {
6447    execute() {
6448        return true;
6449    }
6450}
6451
6452class Transaction {
6453    conn: Connection;
6454    constructor(conn: Connection) {
6455        this.conn = conn;
6456    }
6457
6458    run() {
6459        return conn.execute();
6460    }
6461}
6462",
6463        );
6464
6465        let (graph, _) = EntityGraph::build(root, &["service.ts".into()], &registry);
6466
6467        let run_id = graph
6468            .entities
6469            .keys()
6470            .find(|id| id.contains("Transaction") && id.contains("run"))
6471            .expect("Transaction.run entity should exist");
6472        let deps = graph.get_dependencies(run_id);
6473        assert!(
6474            !deps.iter().any(|d| {
6475                d.name == "execute"
6476                    && d.parent_id
6477                        .as_deref()
6478                        .map_or(false, |parent| parent.contains("Connection"))
6479            }),
6480            "bare conn.execute() should not resolve through a TypeScript instance property. Deps: {:?}",
6481            deps.iter()
6482                .map(|d| (&d.name, &d.parent_id))
6483                .collect::<Vec<_>>()
6484        );
6485    }
6486
6487    #[test]
6488    fn test_dot_chain_class_static() {
6489        let (dir, registry) = create_test_repo();
6490        let root = dir.path();
6491
6492        write_file(
6493            root,
6494            "utils.ts",
6495            "\
6496class MathUtils {
6497    static compute() { return 1; }
6498}
6499function caller() { return MathUtils.compute(); }
6500",
6501        );
6502
6503        let (graph, _) = EntityGraph::build(root, &["utils.ts".into()], &registry);
6504
6505        let caller_id = graph
6506            .entities
6507            .keys()
6508            .find(|id| id.contains("caller"))
6509            .expect("caller entity should exist");
6510        let deps = graph.get_dependencies(caller_id);
6511        assert!(
6512            deps.iter().any(|d| d.name == "compute"),
6513            "caller should depend on compute via MathUtils.compute(). Deps: {:?}",
6514            deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6515        );
6516    }
6517
6518    #[test]
6519    fn test_protocols_are_member_containers() {
6520        assert!(is_nominal_member_container("protocol"));
6521        assert!(is_scope_member_container("protocol"));
6522    }
6523
6524    #[test]
6525    fn test_js_ts_import_resolution() {
6526        let (dir, registry) = create_test_repo();
6527        let root = dir.path();
6528
6529        write_file(
6530            root,
6531            "helper.ts",
6532            "\
6533export function helper() { return 1; }
6534",
6535        );
6536        write_file(
6537            root,
6538            "main.ts",
6539            "\
6540import { helper } from './helper';
6541export function main() { return helper(); }
6542",
6543        );
6544
6545        let (graph, _) =
6546            EntityGraph::build(root, &["helper.ts".into(), "main.ts".into()], &registry);
6547
6548        let main_id = graph
6549            .entities
6550            .keys()
6551            .find(|id| id.contains("main"))
6552            .expect("main entity should exist");
6553        let deps = graph.get_dependencies(main_id);
6554        assert!(
6555            deps.iter().any(|d| d.name == "helper"),
6556            "main should depend on helper via JS import. Deps: {:?}",
6557            deps.iter().map(|d| &d.name).collect::<Vec<_>>()
6558        );
6559    }
6560
6561    #[test]
6562    fn test_direct_dependencies_match_full_graph_for_js_ts_import_forms() {
6563        let (dir, registry) = create_test_repo();
6564        let root = dir.path();
6565
6566        write_file(
6567            root,
6568            "lib.ts",
6569            "\
6570export function namedThing() { return 1; }
6571export default function defaultThing() { return 2; }
6572",
6573        );
6574        write_file(
6575            root,
6576            "consumer.ts",
6577            "\
6578import defaultThing, { namedThing } from './lib';
6579import * as lib from './lib';
6580
6581export function useEverything() {
6582    return defaultThing() + namedThing() + lib.namedThing();
6583}
6584",
6585        );
6586        let files = vec!["lib.ts".to_string(), "consumer.ts".to_string()];
6587        assert_direct_dependencies_match_full(
6588            root,
6589            &files,
6590            &registry,
6591            "consumer.ts::function::useEverything",
6592        );
6593    }
6594
6595    #[test]
6596    fn test_js_ts_relative_import_resolution_uses_full_path() {
6597        let (dir, registry) = create_test_repo();
6598        let root = dir.path();
6599
6600        write_file(
6601            root,
6602            "src/a/util.ts",
6603            "\
6604export function helper() { return 1; }
6605",
6606        );
6607        write_file(
6608            root,
6609            "src/b/util.ts",
6610            "\
6611export function helper() { return 2; }
6612",
6613        );
6614        write_file(
6615            root,
6616            "src/main.ts",
6617            "\
6618import { helper } from './b/util';
6619export function caller() { return helper(); }
6620",
6621        );
6622
6623        let (graph, _) = EntityGraph::build(
6624            root,
6625            &[
6626                "src/a/util.ts".into(),
6627                "src/b/util.ts".into(),
6628                "src/main.ts".into(),
6629            ],
6630            &registry,
6631        );
6632
6633        let caller_id = graph
6634            .entities
6635            .keys()
6636            .find(|id| id.contains("caller"))
6637            .expect("caller entity should exist");
6638        let deps = graph.get_dependencies(caller_id);
6639        assert!(
6640            deps.iter()
6641                .any(|d| d.name == "helper" && d.file_path == "src/b/util.ts"),
6642            "caller should resolve helper to src/b/util.ts. Deps: {:?}",
6643            deps.iter()
6644                .map(|d| (&d.name, &d.file_path))
6645                .collect::<Vec<_>>()
6646        );
6647        assert!(
6648            !deps
6649                .iter()
6650                .any(|d| d.name == "helper" && d.file_path == "src/a/util.ts"),
6651            "caller should not resolve helper to src/a/util.ts. Deps: {:?}",
6652            deps.iter()
6653                .map(|d| (&d.name, &d.file_path))
6654                .collect::<Vec<_>>()
6655        );
6656    }
6657
6658    #[test]
6659    fn test_js_ts_relative_import_with_extension_prefers_exact_file() {
6660        let (dir, registry) = create_test_repo();
6661        let root = dir.path();
6662
6663        write_file(
6664            root,
6665            "src/util.js",
6666            "\
6667export function helper() { return 1; }
6668",
6669        );
6670        write_file(
6671            root,
6672            "src/util.ts",
6673            "\
6674export function helper() { return 2; }
6675",
6676        );
6677        write_file(
6678            root,
6679            "src/main.ts",
6680            "\
6681import { helper } from './util.ts';
6682export function caller() { return helper(); }
6683",
6684        );
6685
6686        let (graph, _) = EntityGraph::build(
6687            root,
6688            &[
6689                "src/util.js".into(),
6690                "src/util.ts".into(),
6691                "src/main.ts".into(),
6692            ],
6693            &registry,
6694        );
6695
6696        let caller_id = graph
6697            .entities
6698            .keys()
6699            .find(|id| id.contains("caller"))
6700            .expect("caller entity should exist");
6701        let deps = graph.get_dependencies(caller_id);
6702        assert!(
6703            deps.iter()
6704                .any(|d| d.name == "helper" && d.file_path == "src/util.ts"),
6705            "caller should resolve helper to explicit src/util.ts. Deps: {:?}",
6706            deps.iter()
6707                .map(|d| (&d.name, &d.file_path))
6708                .collect::<Vec<_>>()
6709        );
6710        assert!(
6711            !deps
6712                .iter()
6713                .any(|d| d.name == "helper" && d.file_path == "src/util.js"),
6714            "caller should not resolve explicit ./util.ts to src/util.js. Deps: {:?}",
6715            deps.iter()
6716                .map(|d| (&d.name, &d.file_path))
6717                .collect::<Vec<_>>()
6718        );
6719    }
6720
6721    #[test]
6722    fn test_js_ts_default_import_resolves_static_member() {
6723        let (dir, registry) = create_test_repo();
6724        let root = dir.path();
6725
6726        write_file(
6727            root,
6728            "base.ts",
6729            "\
6730export default class Widget {
6731  static make(): string { return 'ok'; }
6732}
6733",
6734        );
6735        write_file(
6736            root,
6737            "consumer.ts",
6738            "\
6739import RenamedWidget from './base';
6740export function useWidget(): string { return RenamedWidget.make(); }
6741",
6742        );
6743
6744        let (graph, _) =
6745            EntityGraph::build(root, &["base.ts".into(), "consumer.ts".into()], &registry);
6746
6747        let use_widget_id = graph
6748            .entities
6749            .keys()
6750            .find(|id| id.contains("useWidget"))
6751            .expect("useWidget entity should exist");
6752        let deps = graph.get_dependencies(use_widget_id);
6753        assert!(
6754            deps.iter()
6755                .any(|d| d.name == "make" && d.file_path == "base.ts"),
6756            "default import alias should resolve the static member. Deps: {:?}",
6757            deps.iter()
6758                .map(|d| (&d.name, &d.file_path))
6759                .collect::<Vec<_>>()
6760        );
6761    }
6762
6763    #[test]
6764    fn test_js_ts_re_export_alias_resolves_through_barrel() {
6765        let (dir, registry) = create_test_repo();
6766        let root = dir.path();
6767
6768        write_file(
6769            root,
6770            "lib.ts",
6771            "\
6772export function core(): string { return 'core'; }
6773",
6774        );
6775        write_file(
6776            root,
6777            "barrel.ts",
6778            "\
6779export { core as publicCore } from './lib';
6780",
6781        );
6782        write_file(
6783            root,
6784            "consumer.ts",
6785            "\
6786import { publicCore } from './barrel';
6787export function usePublicCore(): string { return publicCore(); }
6788",
6789        );
6790
6791        let (graph, _) = EntityGraph::build(
6792            root,
6793            &["lib.ts".into(), "barrel.ts".into(), "consumer.ts".into()],
6794            &registry,
6795        );
6796
6797        let public_core = graph
6798            .entities
6799            .values()
6800            .find(|entity| {
6801                entity.name == "publicCore"
6802                    && entity.file_path == "barrel.ts"
6803                    && entity.entity_type == "export"
6804            })
6805            .expect("barrel export alias entity should exist");
6806        let alias_deps = graph.get_dependencies(&public_core.id);
6807        assert!(
6808            alias_deps
6809                .iter()
6810                .any(|d| d.name == "core" && d.file_path == "lib.ts"),
6811            "barrel export alias should depend on lib.ts core. Deps: {:?}",
6812            alias_deps
6813                .iter()
6814                .map(|d| (&d.name, &d.file_path))
6815                .collect::<Vec<_>>()
6816        );
6817
6818        let use_public_core_id = graph
6819            .entities
6820            .keys()
6821            .find(|id| id.contains("usePublicCore"))
6822            .expect("usePublicCore entity should exist");
6823        let consumer_deps = graph.get_dependencies(use_public_core_id);
6824        assert!(
6825            consumer_deps
6826                .iter()
6827                .any(|d| d.name == "publicCore" && d.file_path == "barrel.ts"),
6828            "consumer should resolve publicCore through the barrel export. Deps: {:?}",
6829            consumer_deps
6830                .iter()
6831                .map(|d| (&d.name, &d.file_path))
6832                .collect::<Vec<_>>()
6833        );
6834    }
6835
6836    #[test]
6837    fn test_direct_dependencies_match_full_graph_for_js_ts_re_exports() {
6838        let (dir, registry) = create_test_repo();
6839        let root = dir.path();
6840
6841        write_file(
6842            root,
6843            "lib.ts",
6844            "\
6845export function core(): string { return 'core'; }
6846",
6847        );
6848        write_file(
6849            root,
6850            "barrel.ts",
6851            "\
6852export { core as publicCore } from './lib';
6853",
6854        );
6855        write_file(
6856            root,
6857            "consumer.ts",
6858            "\
6859import { publicCore } from './barrel';
6860export function usePublicCore(): string { return publicCore(); }
6861",
6862        );
6863        let files = vec![
6864            "lib.ts".to_string(),
6865            "barrel.ts".to_string(),
6866            "consumer.ts".to_string(),
6867        ];
6868
6869        assert_direct_dependencies_match_full(
6870            root,
6871            &files,
6872            &registry,
6873            "barrel.ts::export::publicCore",
6874        );
6875        assert_direct_dependencies_match_full(
6876            root,
6877            &files,
6878            &registry,
6879            "consumer.ts::function::usePublicCore",
6880        );
6881    }
6882
6883    #[test]
6884    fn test_js_ts_namespace_import_resolves_re_export_alias() {
6885        let (dir, registry) = create_test_repo();
6886        let root = dir.path();
6887
6888        write_file(
6889            root,
6890            "lib.ts",
6891            "\
6892export function core(): string { return 'core'; }
6893",
6894        );
6895        write_file(
6896            root,
6897            "barrel.ts",
6898            "\
6899export { core as publicCore } from './lib';
6900",
6901        );
6902        write_file(
6903            root,
6904            "consumer.ts",
6905            "\
6906import * as barrel from './barrel';
6907export function usePublicCore(): string { return barrel.publicCore(); }
6908",
6909        );
6910
6911        let (graph, _) = EntityGraph::build(
6912            root,
6913            &["lib.ts".into(), "barrel.ts".into(), "consumer.ts".into()],
6914            &registry,
6915        );
6916
6917        let use_public_core_id = graph
6918            .entities
6919            .keys()
6920            .find(|id| id.contains("usePublicCore"))
6921            .expect("usePublicCore entity should exist");
6922        let consumer_deps = graph.get_dependencies(use_public_core_id);
6923        assert!(
6924            consumer_deps
6925                .iter()
6926                .any(|d| d.name == "publicCore" && d.file_path == "barrel.ts"),
6927            "namespace import should resolve the exported barrel alias. Deps: {:?}",
6928            consumer_deps
6929                .iter()
6930                .map(|d| (&d.name, &d.file_path))
6931                .collect::<Vec<_>>()
6932        );
6933    }
6934
6935    #[test]
6936    fn test_js_ts_object_literal_receiver_resolves_owned_member() {
6937        let (dir, registry) = create_test_repo();
6938        let root = dir.path();
6939
6940        write_file(
6941            root,
6942            "service.ts",
6943            "\
6944export const other = {
6945    open() { return 'other'; }
6946};
6947export const svc = {
6948    open() { return 'svc'; }
6949};
6950export function run(): string {
6951    return svc.open();
6952}
6953",
6954        );
6955
6956        let (graph, _) = EntityGraph::build(root, &["service.ts".into()], &registry);
6957
6958        let run_id = graph
6959            .entities
6960            .keys()
6961            .find(|id| id.contains("run"))
6962            .expect("run entity should exist");
6963        let deps = graph.get_dependencies(run_id);
6964        assert!(
6965            deps.iter()
6966                .any(|d| d.name == "open"
6967                    && d.parent_id.as_deref().is_some_and(|id| id.contains("svc"))),
6968            "svc.open() should resolve to the object literal member owned by svc. Deps: {:?}",
6969            deps.iter()
6970                .map(|d| (&d.name, &d.parent_id))
6971                .collect::<Vec<_>>()
6972        );
6973        assert!(
6974            !deps.iter().any(|d| d.name == "open"
6975                && d.parent_id
6976                    .as_deref()
6977                    .is_some_and(|id| id.contains("other"))),
6978            "svc.open() should not resolve to another object literal member. Deps: {:?}",
6979            deps.iter()
6980                .map(|d| (&d.name, &d.parent_id))
6981                .collect::<Vec<_>>()
6982        );
6983    }
6984
6985    #[test]
6986    fn test_python_relative_import_resolution_uses_full_path() {
6987        let (dir, registry) = create_test_repo();
6988        let root = dir.path();
6989
6990        write_file(
6991            root,
6992            "src/a/util.py",
6993            "\
6994def helper():
6995    return 1
6996",
6997        );
6998        write_file(
6999            root,
7000            "src/b/util.py",
7001            "\
7002def helper():
7003    return 2
7004",
7005        );
7006        write_file(
7007            root,
7008            "src/main.py",
7009            "\
7010from .b.util import helper
7011
7012def caller():
7013    return helper()
7014",
7015        );
7016
7017        let (graph, _) = EntityGraph::build(
7018            root,
7019            &[
7020                "src/a/util.py".into(),
7021                "src/b/util.py".into(),
7022                "src/main.py".into(),
7023            ],
7024            &registry,
7025        );
7026
7027        let caller_id = graph
7028            .entities
7029            .keys()
7030            .find(|id| id.contains("caller"))
7031            .expect("caller entity should exist");
7032        let deps = graph.get_dependencies(caller_id);
7033        assert!(
7034            deps.iter()
7035                .any(|d| d.name == "helper" && d.file_path == "src/b/util.py"),
7036            "caller should resolve helper to src/b/util.py. Deps: {:?}",
7037            deps.iter()
7038                .map(|d| (&d.name, &d.file_path))
7039                .collect::<Vec<_>>()
7040        );
7041        assert!(
7042            !deps
7043                .iter()
7044                .any(|d| d.name == "helper" && d.file_path == "src/a/util.py"),
7045            "caller should not resolve helper to src/a/util.py. Deps: {:?}",
7046            deps.iter()
7047                .map(|d| (&d.name, &d.file_path))
7048                .collect::<Vec<_>>()
7049        );
7050    }
7051
7052    #[test]
7053    fn test_python_absolute_import_resolution_uses_full_path() {
7054        let (dir, registry) = create_test_repo();
7055        let root = dir.path();
7056
7057        write_file(
7058            root,
7059            "src/a/util.py",
7060            "\
7061def helper():
7062    return 1
7063",
7064        );
7065        write_file(
7066            root,
7067            "src/b/util.py",
7068            "\
7069def helper():
7070    return 2
7071",
7072        );
7073        write_file(
7074            root,
7075            "src/main.py",
7076            "\
7077from src.b.util import helper
7078
7079def caller():
7080    return helper()
7081",
7082        );
7083
7084        let (graph, _) = EntityGraph::build(
7085            root,
7086            &[
7087                "src/a/util.py".into(),
7088                "src/b/util.py".into(),
7089                "src/main.py".into(),
7090            ],
7091            &registry,
7092        );
7093
7094        let caller_id = graph
7095            .entities
7096            .keys()
7097            .find(|id| id.contains("caller"))
7098            .expect("caller entity should exist");
7099        let deps = graph.get_dependencies(caller_id);
7100        assert!(
7101            deps.iter()
7102                .any(|d| d.name == "helper" && d.file_path == "src/b/util.py"),
7103            "caller should resolve helper to src/b/util.py. Deps: {:?}",
7104            deps.iter()
7105                .map(|d| (&d.name, &d.file_path))
7106                .collect::<Vec<_>>()
7107        );
7108        assert!(
7109            !deps
7110                .iter()
7111                .any(|d| d.name == "helper" && d.file_path == "src/a/util.py"),
7112            "caller should not resolve helper to src/a/util.py. Deps: {:?}",
7113            deps.iter()
7114                .map(|d| (&d.name, &d.file_path))
7115                .collect::<Vec<_>>()
7116        );
7117    }
7118
7119    #[test]
7120    fn test_js_ts_named_import_does_not_resolve_unrelated_method_receiver() {
7121        let (dir, registry) = create_test_repo();
7122        let root = dir.path();
7123
7124        write_file(
7125            root,
7126            "lib.ts",
7127            "\
7128export function foo() { return 1; }
7129",
7130        );
7131        write_file(
7132            root,
7133            "main.ts",
7134            "\
7135import { foo } from './lib';
7136export function caller(other) { return other.foo(); }
7137export function actual() { return foo(); }
7138",
7139        );
7140
7141        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7142
7143        let caller_id = graph
7144            .entities
7145            .keys()
7146            .find(|id| id.contains("caller"))
7147            .expect("caller entity should exist");
7148        let caller_deps = graph.get_dependencies(caller_id);
7149        assert!(
7150            !caller_deps
7151                .iter()
7152                .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7153            "other.foo() should not resolve through a bare named import. Deps: {:?}",
7154            caller_deps
7155                .iter()
7156                .map(|d| (&d.name, &d.file_path))
7157                .collect::<Vec<_>>()
7158        );
7159
7160        let actual_id = graph
7161            .entities
7162            .keys()
7163            .find(|id| id.contains("actual"))
7164            .expect("actual entity should exist");
7165        let actual_deps = graph.get_dependencies(actual_id);
7166        assert!(
7167            actual_deps
7168                .iter()
7169                .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7170            "foo() should still resolve through the named import. Deps: {:?}",
7171            actual_deps
7172                .iter()
7173                .map(|d| (&d.name, &d.file_path))
7174                .collect::<Vec<_>>()
7175        );
7176    }
7177
7178    #[test]
7179    fn test_unresolved_method_does_not_block_unrelated_fallback_import() {
7180        let (dir, registry) = create_test_repo();
7181        let root = dir.path();
7182
7183        write_file(
7184            root,
7185            "lib.ts",
7186            "\
7187export const answer = 1;
7188export function foo() { return 1; }
7189",
7190        );
7191        write_file(
7192            root,
7193            "main.ts",
7194            "\
7195import { answer, foo } from './lib';
7196export function caller(other) {
7197    other.foo();
7198    return answer;
7199}
7200",
7201        );
7202
7203        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7204
7205        let caller_id = graph
7206            .entities
7207            .keys()
7208            .find(|id| id.contains("caller"))
7209            .expect("caller entity should exist");
7210        let deps = graph.get_dependencies(caller_id);
7211        assert!(
7212            deps.iter()
7213                .any(|d| d.name == "answer" && d.file_path == "lib.ts"),
7214            "unresolved other.foo() should not block bare answer import fallback. Deps: {:?}",
7215            deps.iter()
7216                .map(|d| (&d.name, &d.file_path))
7217                .collect::<Vec<_>>()
7218        );
7219        assert!(
7220            !deps
7221                .iter()
7222                .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7223            "other.foo() should not resolve through the named import. Deps: {:?}",
7224            deps.iter()
7225                .map(|d| (&d.name, &d.file_path))
7226                .collect::<Vec<_>>()
7227        );
7228    }
7229
7230    #[test]
7231    fn test_js_ts_namespace_import_respects_receiver_alias() {
7232        let (dir, registry) = create_test_repo();
7233        let root = dir.path();
7234
7235        write_file(
7236            root,
7237            "lib.ts",
7238            "\
7239export function foo() { return 1; }
7240",
7241        );
7242        write_file(
7243            root,
7244            "other.ts",
7245            "\
7246export function foo() { return 2; }
7247",
7248        );
7249        write_file(
7250            root,
7251            "main.ts",
7252            "\
7253import * as lib from './lib';
7254export function caller(other) { return other.foo(); }
7255export function actual() { return lib.foo(); }
7256",
7257        );
7258
7259        let (graph, _) = EntityGraph::build(
7260            root,
7261            &["lib.ts".into(), "other.ts".into(), "main.ts".into()],
7262            &registry,
7263        );
7264
7265        let caller_id = graph
7266            .entities
7267            .keys()
7268            .find(|id| id.contains("caller"))
7269            .expect("caller entity should exist");
7270        let caller_deps = graph.get_dependencies(caller_id);
7271        assert!(
7272            !caller_deps.iter().any(|d| d.name == "foo"),
7273            "other.foo() should not resolve via namespace import lib. Deps: {:?}",
7274            caller_deps
7275                .iter()
7276                .map(|d| (&d.name, &d.file_path))
7277                .collect::<Vec<_>>()
7278        );
7279
7280        let actual_id = graph
7281            .entities
7282            .keys()
7283            .find(|id| id.contains("actual"))
7284            .expect("actual entity should exist");
7285        let actual_deps = graph.get_dependencies(actual_id);
7286        assert!(
7287            actual_deps
7288                .iter()
7289                .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7290            "lib.foo() should resolve to lib.ts. Deps: {:?}",
7291            actual_deps
7292                .iter()
7293                .map(|d| (&d.name, &d.file_path))
7294                .collect::<Vec<_>>()
7295        );
7296        assert!(
7297            !actual_deps
7298                .iter()
7299                .any(|d| d.name == "foo" && d.file_path == "other.ts"),
7300            "lib.foo() should not resolve to other.ts. Deps: {:?}",
7301            actual_deps
7302                .iter()
7303                .map(|d| (&d.name, &d.file_path))
7304                .collect::<Vec<_>>()
7305        );
7306    }
7307
7308    #[test]
7309    fn test_js_ts_namespace_import_skips_unexported_top_level_entities() {
7310        let (dir, registry) = create_test_repo();
7311        let root = dir.path();
7312
7313        write_file(
7314            root,
7315            "lib.ts",
7316            "\
7317function hidden() { return 1; }
7318export function visible() { return 2; }
7319",
7320        );
7321        write_file(
7322            root,
7323            "main.ts",
7324            "\
7325import * as lib from './lib';
7326export function callVisible() { return lib.visible(); }
7327export function callHidden() { return lib.hidden(); }
7328",
7329        );
7330
7331        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7332
7333        let visible_id = graph
7334            .entities
7335            .keys()
7336            .find(|id| id.contains("callVisible"))
7337            .expect("callVisible entity should exist");
7338        let visible_deps = graph.get_dependencies(visible_id);
7339        assert!(
7340            visible_deps
7341                .iter()
7342                .any(|d| d.name == "visible" && d.file_path == "lib.ts"),
7343            "lib.visible() should resolve to the exported function. Deps: {:?}",
7344            visible_deps
7345                .iter()
7346                .map(|d| (&d.name, &d.file_path))
7347                .collect::<Vec<_>>()
7348        );
7349
7350        let hidden_id = graph
7351            .entities
7352            .keys()
7353            .find(|id| id.contains("callHidden"))
7354            .expect("callHidden entity should exist");
7355        let hidden_deps = graph.get_dependencies(hidden_id);
7356        assert!(
7357            !hidden_deps
7358                .iter()
7359                .any(|d| d.name == "hidden" && d.file_path == "lib.ts"),
7360            "lib.hidden() should not resolve to a module-private function. Deps: {:?}",
7361            hidden_deps
7362                .iter()
7363                .map(|d| (&d.name, &d.file_path))
7364                .collect::<Vec<_>>()
7365        );
7366    }
7367
7368    #[test]
7369    fn test_js_ts_local_binding_shadows_imported_class_receiver() {
7370        let (dir, registry) = create_test_repo();
7371        let root = dir.path();
7372
7373        write_file(
7374            root,
7375            "lib.ts",
7376            "\
7377export class Service {
7378    static run() { return 1; }
7379}
7380",
7381        );
7382        write_file(
7383            root,
7384            "main.ts",
7385            "\
7386import { Service } from './lib';
7387export function caller(Service) { return Service.run(); }
7388",
7389        );
7390
7391        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7392
7393        let caller_id = graph
7394            .entities
7395            .keys()
7396            .find(|id| id.contains("caller"))
7397            .expect("caller entity should exist");
7398        let deps = graph.get_dependencies(caller_id);
7399        assert!(
7400            !deps
7401                .iter()
7402                .any(|d| d.name == "run" && d.file_path == "lib.ts"),
7403            "local parameter Service should shadow imported class receiver. Deps: {:?}",
7404            deps.iter()
7405                .map(|d| (&d.name, &d.file_path))
7406                .collect::<Vec<_>>()
7407        );
7408        assert!(
7409            !deps
7410                .iter()
7411                .any(|d| d.name == "Service" && d.file_path == "lib.ts"),
7412            "local parameter Service should shadow imported class name. Deps: {:?}",
7413            deps.iter()
7414                .map(|d| (&d.name, &d.file_path))
7415                .collect::<Vec<_>>()
7416        );
7417    }
7418
7419    #[test]
7420    fn test_js_ts_local_binding_shadows_namespace_receiver() {
7421        let (dir, registry) = create_test_repo();
7422        let root = dir.path();
7423
7424        write_file(
7425            root,
7426            "lib.ts",
7427            "\
7428export function foo() { return 1; }
7429",
7430        );
7431        write_file(
7432            root,
7433            "main.ts",
7434            "\
7435import * as lib from './lib';
7436export function caller(lib) { return lib.foo(); }
7437",
7438        );
7439
7440        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7441
7442        let caller_id = graph
7443            .entities
7444            .keys()
7445            .find(|id| id.contains("caller"))
7446            .expect("caller entity should exist");
7447        let deps = graph.get_dependencies(caller_id);
7448        assert!(
7449            !deps
7450                .iter()
7451                .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7452            "local parameter lib should shadow namespace import receiver. Deps: {:?}",
7453            deps.iter()
7454                .map(|d| (&d.name, &d.file_path))
7455                .collect::<Vec<_>>()
7456        );
7457    }
7458
7459    #[test]
7460    fn test_js_ts_local_binding_shadows_named_import_call() {
7461        let (dir, registry) = create_test_repo();
7462        let root = dir.path();
7463
7464        write_file(
7465            root,
7466            "lib.ts",
7467            "\
7468export function foo() { return 1; }
7469",
7470        );
7471        write_file(
7472            root,
7473            "main.ts",
7474            "\
7475import { foo } from './lib';
7476export function caller(foo) { return foo(); }
7477",
7478        );
7479
7480        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7481
7482        let caller_id = graph
7483            .entities
7484            .keys()
7485            .find(|id| id.contains("caller"))
7486            .expect("caller entity should exist");
7487        let deps = graph.get_dependencies(caller_id);
7488        assert!(
7489            !deps
7490                .iter()
7491                .any(|d| d.name == "foo" && d.file_path == "lib.ts"),
7492            "local parameter foo should shadow named import. Deps: {:?}",
7493            deps.iter()
7494                .map(|d| (&d.name, &d.file_path))
7495                .collect::<Vec<_>>()
7496        );
7497    }
7498
7499    #[test]
7500    fn test_nested_local_binding_does_not_hide_parent_reference() {
7501        let (dir, registry) = create_test_repo();
7502        let root = dir.path();
7503
7504        write_file(
7505            root,
7506            "lib.ts",
7507            "\
7508export const answer = 42;
7509",
7510        );
7511        write_file(
7512            root,
7513            "main.ts",
7514            "\
7515import { answer } from './lib';
7516export function outer() {
7517    const value = answer;
7518    function inner() {
7519        const answer = 0;
7520        return answer;
7521    }
7522    return value;
7523}
7524",
7525        );
7526
7527        let (graph, _) = EntityGraph::build(root, &["lib.ts".into(), "main.ts".into()], &registry);
7528
7529        let outer_id = graph
7530            .entities
7531            .iter()
7532            .find(|(_, entity)| entity.name == "outer")
7533            .map(|(id, _)| id)
7534            .expect("outer entity should exist");
7535        let outer_deps = graph.get_dependencies(outer_id);
7536        assert!(
7537            outer_deps
7538                .iter()
7539                .any(|d| d.name == "answer" && d.file_path == "lib.ts"),
7540            "parent bare reference to imported answer should remain resolved. Deps: {:?}",
7541            outer_deps
7542                .iter()
7543                .map(|d| (&d.name, &d.file_path))
7544                .collect::<Vec<_>>()
7545        );
7546
7547        let inner_id = graph
7548            .entities
7549            .iter()
7550            .find(|(_, entity)| entity.name == "inner")
7551            .map(|(id, _)| id)
7552            .expect("inner entity should exist");
7553        let inner_deps = graph.get_dependencies(inner_id);
7554        assert!(
7555            !inner_deps
7556                .iter()
7557                .any(|d| d.name == "answer" && d.file_path == "lib.ts"),
7558            "nested local binding answer should not resolve to imported answer. Deps: {:?}",
7559            inner_deps
7560                .iter()
7561                .map(|d| (&d.name, &d.file_path))
7562                .collect::<Vec<_>>()
7563        );
7564    }
7565
7566    #[test]
7567    fn test_python_local_binding_shadows_same_file_function() {
7568        let (dir, registry) = create_test_repo();
7569        let root = dir.path();
7570
7571        write_file(
7572            root,
7573            "b.py",
7574            "\
7575def total(items):
7576    return sum(items)
7577
7578def report():
7579    total = 0
7580    for i in range(10):
7581        total = total + i
7582    return total
7583",
7584        );
7585
7586        let (graph, _) = EntityGraph::build(root, &["b.py".into()], &registry);
7587
7588        let report_id = graph
7589            .entities
7590            .iter()
7591            .find(|(_, entity)| entity.name == "report")
7592            .map(|(id, _)| id)
7593            .expect("report entity should exist");
7594        let deps = graph.get_dependencies(report_id);
7595        assert!(
7596            !deps.iter().any(|d| d.name == "total"),
7597            "local variable total should not resolve to same-file function total. Deps: {:?}",
7598            deps.iter()
7599                .map(|d| (&d.name, &d.file_path))
7600                .collect::<Vec<_>>()
7601        );
7602    }
7603
7604    #[test]
7605    fn test_constructor_return_type_tie_break_uses_stable_source_order() {
7606        let (dir, registry) = create_test_repo();
7607        let root = dir.path();
7608
7609        write_file(
7610            root,
7611            "a_primary.py",
7612            "\
7613class Primary:
7614    def get(self):
7615        return True
7616
7617def make_conn():
7618    return Primary()
7619",
7620        );
7621        write_file(
7622            root,
7623            "holder.py",
7624            "\
7625class Holder:
7626    def __init__(self, conn):
7627        self.conn = conn
7628
7629    def use(self):
7630        return self.conn.get()
7631
7632def wire():
7633    Holder(make_conn())
7634",
7635        );
7636        write_file(
7637            root,
7638            "z_backup.py",
7639            "\
7640class Backup:
7641    def get(self):
7642        return False
7643
7644def make_conn():
7645    return Backup()
7646",
7647        );
7648
7649        let files = vec![
7650            "a_primary.py".to_string(),
7651            "holder.py".to_string(),
7652            "z_backup.py".to_string(),
7653        ];
7654        let (graph, _) = EntityGraph::build(root, &files, &registry);
7655        let graph_payload = graph_json_payload(&graph);
7656
7657        let use_id = graph
7658            .entities
7659            .iter()
7660            .find(|(_, entity)| entity.name == "use")
7661            .map(|(id, _)| id)
7662            .expect("Holder.use entity should exist");
7663        let deps = graph.get_dependencies(use_id);
7664
7665        assert!(
7666            deps.iter().any(|d| {
7667                d.name == "get"
7668                    && d.parent_id
7669                        .as_deref()
7670                        .map_or(false, |parent| parent.contains("Primary"))
7671            }),
7672            "Holder.use should resolve conn.get to Primary.get. Deps: {:?}",
7673            deps.iter()
7674                .map(|d| (&d.name, &d.parent_id))
7675                .collect::<Vec<_>>()
7676        );
7677        assert!(
7678            !deps.iter().any(|d| {
7679                d.name == "get"
7680                    && d.parent_id
7681                        .as_deref()
7682                        .map_or(false, |parent| parent.contains("Backup"))
7683            }),
7684            "Holder.use should not resolve conn.get to Backup.get. Deps: {:?}",
7685            deps.iter()
7686                .map(|d| (&d.name, &d.parent_id))
7687                .collect::<Vec<_>>()
7688        );
7689
7690        for _ in 0..16 {
7691            let (repeat_graph, _) = EntityGraph::build(root, &files, &registry);
7692            assert_eq!(graph_json_payload(&repeat_graph), graph_payload);
7693        }
7694    }
7695
7696    #[test]
7697    fn test_rust_impl_container_does_not_inherit_child_build_call() {
7698        let (dir, registry) = create_test_repo();
7699        let root = dir.path();
7700
7701        write_file(
7702            root,
7703            "graph.rs",
7704            "\
7705pub struct EntityGraph;
7706
7707impl EntityGraph {
7708    pub fn build(a: i32, b: i32, c: i32) -> i32 {
7709        a + b + c
7710    }
7711}
7712",
7713        );
7714        write_file(
7715            root,
7716            "server.rs",
7717            "\
7718use crate::graph::EntityGraph;
7719
7720struct SemServer;
7721
7722impl SemServer {
7723    fn find_supported_files() {
7724        let mut builder = ignore::WalkBuilder::new(\".\");
7725        let walker = builder.build();
7726    }
7727
7728    fn get_or_build_graph() {
7729        let _ = EntityGraph::build(1, 2, 3);
7730    }
7731}
7732
7733impl SemServer {
7734    fn metadata(&self) -> i32 {
7735        1
7736    }
7737}
7738",
7739        );
7740
7741        let (graph, _) =
7742            EntityGraph::build(root, &["graph.rs".into(), "server.rs".into()], &registry);
7743
7744        let sem_server_impls: Vec<_> = graph
7745            .entities
7746            .iter()
7747            .filter(|(_, entity)| entity.entity_type == "impl" && entity.name == "SemServer")
7748            .collect();
7749        assert!(
7750            sem_server_impls.len() >= 2,
7751            "test fixture should produce duplicate SemServer impl entities"
7752        );
7753        for (impl_id, _) in sem_server_impls {
7754            let impl_deps = graph.get_dependencies(impl_id);
7755            assert!(
7756                !impl_deps
7757                    .iter()
7758                    .any(|d| d.name == "build" && d.file_path == "graph.rs"),
7759                "impl container should not inherit child build calls. Deps: {:?}",
7760                impl_deps
7761                    .iter()
7762                    .map(|d| (&d.name, &d.file_path))
7763                    .collect::<Vec<_>>()
7764            );
7765        }
7766
7767        let method_id = graph
7768            .entities
7769            .iter()
7770            .find(|(_, entity)| entity.name == "get_or_build_graph")
7771            .map(|(id, _)| id)
7772            .expect("get_or_build_graph entity should exist");
7773        let method_deps = graph.get_dependencies(method_id);
7774        assert!(
7775            method_deps
7776                .iter()
7777                .any(|d| d.name == "build" && d.file_path == "graph.rs"),
7778            "direct EntityGraph::build call should remain resolved. Deps: {:?}",
7779            method_deps
7780                .iter()
7781                .map(|d| (&d.name, &d.file_path))
7782                .collect::<Vec<_>>()
7783        );
7784    }
7785
7786    #[test]
7787    fn test_rust_lowercase_scoped_path_does_not_fallback_to_local_function() {
7788        let (dir, registry) = create_test_repo();
7789        let root = dir.path();
7790
7791        write_file(
7792            root,
7793            "main.rs",
7794            "\
7795fn baz() {}
7796
7797fn caller() {
7798    foo::bar::baz();
7799}
7800",
7801        );
7802
7803        let (graph, _) = EntityGraph::build(root, &["main.rs".into()], &registry);
7804
7805        let caller_id = graph
7806            .entities
7807            .iter()
7808            .find(|(_, entity)| entity.name == "caller")
7809            .map(|(id, _)| id)
7810            .expect("caller entity should exist");
7811        let deps = graph.get_dependencies(caller_id);
7812        assert!(
7813            !deps.iter().any(|d| d.name == "baz"),
7814            "lowercase scoped path should not fall back to local baz function. Deps: {:?}",
7815            deps.iter()
7816                .map(|d| (&d.name, &d.file_path))
7817                .collect::<Vec<_>>()
7818        );
7819    }
7820
7821    #[test]
7822    fn test_dot_chain_no_false_edges() {
7823        let (dir, registry) = create_test_repo();
7824        let root = dir.path();
7825
7826        // Two classes with same method name "process".
7827        // self.process() in ClassA should NOT create edge to ClassB::process.
7828        write_file(
7829            root,
7830            "a.py",
7831            "\
7832class ClassA:
7833    def run(self):
7834        return self.process()
7835
7836    def process(self):
7837        return 1
7838",
7839        );
7840        write_file(
7841            root,
7842            "b.py",
7843            "\
7844class ClassB:
7845    def process(self):
7846        return 2
7847",
7848        );
7849
7850        let (graph, _) = EntityGraph::build(root, &["a.py".into(), "b.py".into()], &registry);
7851
7852        let run_id = graph
7853            .entities
7854            .keys()
7855            .find(|id| id.contains("run"))
7856            .expect("run entity should exist");
7857        let deps = graph.get_dependencies(run_id);
7858        // Should have edge to ClassA::process, NOT ClassB::process
7859        for dep in &deps {
7860            if dep.name == "process" {
7861                assert!(
7862                    dep.file_path == "a.py",
7863                    "run's process dep should be in a.py, not {}",
7864                    dep.file_path
7865                );
7866            }
7867        }
7868    }
7869
7870    #[test]
7871    fn test_dot_chain_fallback() {
7872        let (dir, registry) = create_test_repo();
7873        let root = dir.path();
7874
7875        // someVar.unknownMethod() - "someVar" is not a class,
7876        // so the chain is unresolved and words fall through to bag-of-words.
7877        // "helper" should still resolve via bag-of-words.
7878        write_file(
7879            root,
7880            "app.ts",
7881            "\
7882export function helper() { return 1; }
7883export function caller() {
7884    const val = helper();
7885    return val;
7886}
7887",
7888        );
7889
7890        let (graph, _) = EntityGraph::build(root, &["app.ts".into()], &registry);
7891
7892        let caller_id = graph
7893            .entities
7894            .keys()
7895            .find(|id| id.contains("caller"))
7896            .expect("caller entity should exist");
7897        let deps = graph.get_dependencies(caller_id);
7898        assert!(
7899            deps.iter().any(|d| d.name == "helper"),
7900            "caller should still resolve helper via bag-of-words. Deps: {:?}",
7901            deps.iter().map(|d| &d.name).collect::<Vec<_>>()
7902        );
7903    }
7904}