Skip to main content

sem_core/parser/
graph.rs

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