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