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