Skip to main content

mir_analyzer/
project.rs

1/// Project-level orchestration: file discovery, pass 1, pass 2.
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use rayon::prelude::*;
6
7use std::collections::{HashMap, HashSet};
8
9use crate::cache::{hash_content, AnalysisCache};
10use mir_codebase::Codebase;
11use mir_issues::Issue;
12use mir_types::Union;
13
14use crate::collector::DefinitionCollector;
15
16// ---------------------------------------------------------------------------
17// ProjectAnalyzer
18// ---------------------------------------------------------------------------
19
20pub struct ProjectAnalyzer {
21    pub codebase: Arc<Codebase>,
22    /// Optional cache — when `Some`, Pass 2 results are read/written per file.
23    pub cache: Option<AnalysisCache>,
24    /// Called once after each file completes Pass 2 (used for progress reporting).
25    pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
26    /// PSR-4 autoloader mapping from composer.json, if available.
27    pub psr4: Option<Arc<crate::composer::Psr4Map>>,
28    /// Whether stubs have already been loaded (to avoid double-loading).
29    stubs_loaded: std::sync::atomic::AtomicBool,
30    /// When true, run dead code detection at the end of analysis.
31    pub find_dead_code: bool,
32}
33
34impl ProjectAnalyzer {
35    pub fn new() -> Self {
36        Self {
37            codebase: Arc::new(Codebase::new()),
38            cache: None,
39            on_file_done: None,
40            psr4: None,
41            stubs_loaded: std::sync::atomic::AtomicBool::new(false),
42            find_dead_code: false,
43        }
44    }
45
46    /// Create a `ProjectAnalyzer` with a disk-backed cache stored under `cache_dir`.
47    pub fn with_cache(cache_dir: &Path) -> Self {
48        Self {
49            codebase: Arc::new(Codebase::new()),
50            cache: Some(AnalysisCache::open(cache_dir)),
51            on_file_done: None,
52            psr4: None,
53            stubs_loaded: std::sync::atomic::AtomicBool::new(false),
54            find_dead_code: false,
55        }
56    }
57
58    /// Create a `ProjectAnalyzer` from a project root containing `composer.json`.
59    /// Returns the analyzer (with `psr4` set) and the `Psr4Map` so callers can
60    /// call `map.project_files()` / `map.vendor_files()`.
61    pub fn from_composer(
62        root: &Path,
63    ) -> Result<(Self, crate::composer::Psr4Map), crate::composer::ComposerError> {
64        let map = crate::composer::Psr4Map::from_composer(root)?;
65        let psr4 = Arc::new(map.clone());
66        let analyzer = Self {
67            codebase: Arc::new(Codebase::new()),
68            cache: None,
69            on_file_done: None,
70            psr4: Some(psr4),
71            stubs_loaded: std::sync::atomic::AtomicBool::new(false),
72            find_dead_code: false,
73        };
74        Ok((analyzer, map))
75    }
76
77    /// Expose codebase for external use (e.g., pre-loading stubs from CLI).
78    pub fn codebase(&self) -> &Arc<Codebase> {
79        &self.codebase
80    }
81
82    /// Load PHP built-in stubs. Called automatically by `analyze` if not done yet.
83    pub fn load_stubs(&self) {
84        if !self
85            .stubs_loaded
86            .swap(true, std::sync::atomic::Ordering::SeqCst)
87        {
88            crate::stubs::load_stubs(&self.codebase);
89        }
90    }
91
92    /// Run the full analysis pipeline on a set of file paths.
93    pub fn analyze(&self, paths: &[PathBuf]) -> AnalysisResult {
94        let mut all_issues = Vec::new();
95        let mut parse_errors = Vec::new();
96
97        // ---- Load PHP built-in stubs (before Pass 1 so user code can override)
98        self.load_stubs();
99
100        // ---- Pre-Pass-2 invalidation: evict dependents of changed files ------
101        // Uses the reverse dep graph persisted from the previous run.
102        if let Some(cache) = &self.cache {
103            let changed: Vec<String> = paths
104                .iter()
105                .filter_map(|p| {
106                    let path_str = p.to_string_lossy().into_owned();
107                    let content = std::fs::read_to_string(p).ok()?;
108                    let h = hash_content(&content);
109                    if cache.get(&path_str, &h).is_none() {
110                        Some(path_str)
111                    } else {
112                        None
113                    }
114                })
115                .collect();
116            if !changed.is_empty() {
117                cache.evict_with_dependents(&changed);
118            }
119        }
120
121        // ---- Pass 1: read files in parallel ----------------------------------
122        let file_data: Vec<(Arc<str>, String)> = paths
123            .par_iter()
124            .filter_map(|path| match std::fs::read_to_string(path) {
125                Ok(src) => Some((Arc::from(path.to_string_lossy().as_ref()), src)),
126                Err(e) => {
127                    eprintln!("Cannot read {}: {}", path.display(), e);
128                    None
129                }
130            })
131            .collect();
132
133        // ---- Pre-index pass: walk the AST to build FQCN index, file imports, and namespaces ---
134        file_data.par_iter().for_each(|(file, src)| {
135            use php_ast::ast::StmtKind;
136            let arena = bumpalo::Bump::new();
137            let result = php_rs_parser::parse(&arena, src);
138
139            let mut current_namespace: Option<String> = None;
140            let mut imports: std::collections::HashMap<String, String> =
141                std::collections::HashMap::new();
142            let mut file_ns_set = false;
143
144            // Index a flat list of stmts under a given namespace prefix.
145            let index_stmts =
146                |stmts: &[php_ast::ast::Stmt<'_, '_>],
147                 ns: Option<&str>,
148                 imports: &mut std::collections::HashMap<String, String>| {
149                    for stmt in stmts.iter() {
150                        match &stmt.kind {
151                            StmtKind::Use(use_decl) => {
152                                for item in use_decl.uses.iter() {
153                                    let full_name = crate::parser::name_to_string(&item.name);
154                                    let alias = item.alias.unwrap_or_else(|| {
155                                        full_name.rsplit('\\').next().unwrap_or(&full_name)
156                                    });
157                                    imports.insert(alias.to_string(), full_name);
158                                }
159                            }
160                            StmtKind::Class(decl) => {
161                                if let Some(n) = decl.name {
162                                    let fqcn = match ns {
163                                        Some(ns) => format!("{}\\{}", ns, n),
164                                        None => n.to_string(),
165                                    };
166                                    self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
167                                }
168                            }
169                            StmtKind::Interface(decl) => {
170                                let fqcn = match ns {
171                                    Some(ns) => format!("{}\\{}", ns, decl.name),
172                                    None => decl.name.to_string(),
173                                };
174                                self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
175                            }
176                            StmtKind::Trait(decl) => {
177                                let fqcn = match ns {
178                                    Some(ns) => format!("{}\\{}", ns, decl.name),
179                                    None => decl.name.to_string(),
180                                };
181                                self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
182                            }
183                            StmtKind::Enum(decl) => {
184                                let fqcn = match ns {
185                                    Some(ns) => format!("{}\\{}", ns, decl.name),
186                                    None => decl.name.to_string(),
187                                };
188                                self.codebase.known_symbols.insert(Arc::from(fqcn.as_str()));
189                            }
190                            StmtKind::Function(decl) => {
191                                let fqn = match ns {
192                                    Some(ns) => format!("{}\\{}", ns, decl.name),
193                                    None => decl.name.to_string(),
194                                };
195                                self.codebase.known_symbols.insert(Arc::from(fqn.as_str()));
196                            }
197                            _ => {}
198                        }
199                    }
200                };
201
202            for stmt in result.program.stmts.iter() {
203                match &stmt.kind {
204                    StmtKind::Namespace(ns) => {
205                        current_namespace =
206                            ns.name.as_ref().map(|n| crate::parser::name_to_string(n));
207                        if !file_ns_set {
208                            if let Some(ref ns_str) = current_namespace {
209                                self.codebase
210                                    .file_namespaces
211                                    .insert(file.clone(), ns_str.clone());
212                                file_ns_set = true;
213                            }
214                        }
215                        // Bracketed namespace: walk inner stmts for Use/Class/etc.
216                        if let php_ast::ast::NamespaceBody::Braced(inner_stmts) = &ns.body {
217                            index_stmts(inner_stmts, current_namespace.as_deref(), &mut imports);
218                        }
219                    }
220                    _ => index_stmts(
221                        std::slice::from_ref(stmt),
222                        current_namespace.as_deref(),
223                        &mut imports,
224                    ),
225                }
226            }
227
228            if !imports.is_empty() {
229                self.codebase.file_imports.insert(file.clone(), imports);
230            }
231        });
232
233        // ---- Pass 1: definition collection (sequential) -------------------------
234        // DashMap handles concurrent writes, but sequential avoids contention.
235        for (file, src) in &file_data {
236            let arena = bumpalo::Bump::new();
237            let result = php_rs_parser::parse(&arena, src);
238
239            for err in &result.errors {
240                let msg: String = err.to_string();
241                parse_errors.push(Issue::new(
242                    mir_issues::IssueKind::ParseError { message: msg },
243                    mir_issues::Location {
244                        file: file.clone(),
245                        line: 1,
246                        col_start: 0,
247                        col_end: 0,
248                    },
249                ));
250            }
251
252            let collector =
253                DefinitionCollector::new(&self.codebase, file.clone(), src, &result.source_map);
254            let issues = collector.collect(&result.program);
255            all_issues.extend(issues);
256        }
257
258        all_issues.extend(parse_errors);
259
260        // ---- Finalize codebase (resolve inheritance, build dispatch tables) --
261        self.codebase.finalize();
262
263        // ---- Lazy-load unknown classes via PSR-4 (issue #50) ----------------
264        if let Some(psr4) = &self.psr4 {
265            self.lazy_load_missing_classes(psr4.clone(), &mut all_issues);
266        }
267
268        // ---- Build reverse dep graph and persist it for the next run ---------
269        if let Some(cache) = &self.cache {
270            let rev = build_reverse_deps(&self.codebase);
271            cache.set_reverse_deps(rev);
272        }
273
274        // ---- Class-level checks (M11) ----------------------------------------
275        let analyzed_file_set: std::collections::HashSet<std::sync::Arc<str>> =
276            file_data.iter().map(|(f, _)| f.clone()).collect();
277        let class_issues =
278            crate::class::ClassAnalyzer::with_files(&self.codebase, analyzed_file_set, &file_data)
279                .analyze_all();
280        all_issues.extend(class_issues);
281
282        // ---- Pass 2: analyze function/method bodies in parallel (M14) --------
283        // Each file is analyzed independently; arena + parse happen inside the
284        // rayon closure so there is no cross-thread borrow.
285        // When a cache is present, files whose content hash matches a stored
286        // entry skip re-analysis entirely (M17).
287        let pass2_results: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>)> = file_data
288            .par_iter()
289            .map(|(file, src)| {
290                // Cache lookup
291                let result = if let Some(cache) = &self.cache {
292                    let h = hash_content(src);
293                    if let Some(cached) = cache.get(file, &h) {
294                        (cached, Vec::new())
295                    } else {
296                        // Miss — analyze and store
297                        let arena = bumpalo::Bump::new();
298                        let parsed = php_rs_parser::parse(&arena, src);
299                        let (issues, symbols) = self.analyze_bodies(
300                            &parsed.program,
301                            file.clone(),
302                            src,
303                            &parsed.source_map,
304                        );
305                        cache.put(file, h, issues.clone());
306                        (issues, symbols)
307                    }
308                } else {
309                    let arena = bumpalo::Bump::new();
310                    let parsed = php_rs_parser::parse(&arena, src);
311                    self.analyze_bodies(&parsed.program, file.clone(), src, &parsed.source_map)
312                };
313                if let Some(cb) = &self.on_file_done {
314                    cb();
315                }
316                result
317            })
318            .collect();
319
320        let mut all_symbols = Vec::new();
321        for (issues, symbols) in pass2_results {
322            all_issues.extend(issues);
323            all_symbols.extend(symbols);
324        }
325
326        // Persist cache hits/misses to disk
327        if let Some(cache) = &self.cache {
328            cache.flush();
329        }
330
331        // ---- Dead-code detection (M18) --------------------------------------
332        if self.find_dead_code {
333            let dead_code_issues =
334                crate::dead_code::DeadCodeAnalyzer::new(&self.codebase).analyze();
335            all_issues.extend(dead_code_issues);
336        }
337
338        AnalysisResult {
339            issues: all_issues,
340            type_envs: std::collections::HashMap::new(),
341            symbols: all_symbols,
342        }
343    }
344
345    /// Lazily load class definitions for referenced-but-unknown FQCNs via PSR-4.
346    ///
347    /// After Pass 1 and `codebase.finalize()`, some classes referenced as parents
348    /// or interfaces may not be in the codebase (they weren't in the initial file
349    /// list). This method iterates up to `max_depth` times, each time resolving
350    /// unknown parent/interface FQCNs via the PSR-4 map, running Pass 1 on those
351    /// files, and re-finalizing the codebase. The loop stops when no new files
352    /// are discovered.
353    fn lazy_load_missing_classes(
354        &self,
355        psr4: Arc<crate::composer::Psr4Map>,
356        all_issues: &mut Vec<Issue>,
357    ) {
358        use std::collections::HashSet;
359
360        let max_depth = 10; // prevent infinite chains
361        let mut loaded: HashSet<String> = HashSet::new();
362
363        for _ in 0..max_depth {
364            // Collect all referenced FQCNs that aren't in the codebase
365            let mut to_load: Vec<(String, PathBuf)> = Vec::new();
366
367            for entry in self.codebase.classes.iter() {
368                let cls = entry.value();
369
370                // Check parent class
371                if let Some(parent) = &cls.parent {
372                    let fqcn = parent.as_ref();
373                    if !self.codebase.classes.contains_key(fqcn) && !loaded.contains(fqcn) {
374                        if let Some(path) = psr4.resolve(fqcn) {
375                            to_load.push((fqcn.to_string(), path));
376                        }
377                    }
378                }
379
380                // Check interfaces
381                for iface in &cls.interfaces {
382                    let fqcn = iface.as_ref();
383                    if !self.codebase.classes.contains_key(fqcn)
384                        && !self.codebase.interfaces.contains_key(fqcn)
385                        && !loaded.contains(fqcn)
386                    {
387                        if let Some(path) = psr4.resolve(fqcn) {
388                            to_load.push((fqcn.to_string(), path));
389                        }
390                    }
391                }
392            }
393
394            if to_load.is_empty() {
395                break;
396            }
397
398            // Load each discovered file (Pass 1 only)
399            for (fqcn, path) in to_load {
400                loaded.insert(fqcn);
401                if let Ok(src) = std::fs::read_to_string(&path) {
402                    let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
403                    let arena = bumpalo::Bump::new();
404                    let result = php_rs_parser::parse(&arena, &src);
405                    let collector = crate::collector::DefinitionCollector::new(
406                        &self.codebase,
407                        file,
408                        &src,
409                        &result.source_map,
410                    );
411                    let issues = collector.collect(&result.program);
412                    all_issues.extend(issues);
413                }
414            }
415
416            // Re-finalize to include newly loaded classes in the inheritance graph.
417            // Must reset the flag first so finalize() isn't a no-op.
418            self.codebase.invalidate_finalization();
419            self.codebase.finalize();
420        }
421    }
422
423    /// Re-analyze a single file within the existing codebase.
424    ///
425    /// This is the incremental analysis API for LSP:
426    /// 1. Removes old definitions from this file
427    /// 2. Re-runs Pass 1 (definition collection) on the new content
428    /// 3. Re-finalizes the codebase (rebuilds inheritance)
429    /// 4. Re-runs Pass 2 (body analysis) on this file
430    /// 5. Returns the analysis result for this file only
431    pub fn re_analyze_file(&self, file_path: &str, new_content: &str) -> AnalysisResult {
432        // 1. Remove old definitions from this file
433        self.codebase.remove_file_definitions(file_path);
434
435        // 2. Parse new content and run Pass 1
436        let file: Arc<str> = Arc::from(file_path);
437        let arena = bumpalo::Bump::new();
438        let parsed = php_rs_parser::parse(&arena, new_content);
439
440        let mut all_issues = Vec::new();
441
442        // Collect parse errors
443        for err in &parsed.errors {
444            all_issues.push(Issue::new(
445                mir_issues::IssueKind::ParseError {
446                    message: err.to_string(),
447                },
448                mir_issues::Location {
449                    file: file.clone(),
450                    line: 1,
451                    col_start: 0,
452                    col_end: 0,
453                },
454            ));
455        }
456
457        let collector = DefinitionCollector::new(
458            &self.codebase,
459            file.clone(),
460            new_content,
461            &parsed.source_map,
462        );
463        all_issues.extend(collector.collect(&parsed.program));
464
465        // 3. Re-finalize (invalidation already done by remove_file_definitions)
466        self.codebase.finalize();
467
468        // 4. Run Pass 2 on this file
469        let (body_issues, symbols) = self.analyze_bodies(
470            &parsed.program,
471            file.clone(),
472            new_content,
473            &parsed.source_map,
474        );
475        all_issues.extend(body_issues);
476
477        // 5. Update cache if present
478        if let Some(cache) = &self.cache {
479            let h = hash_content(new_content);
480            cache.evict_with_dependents(&[file_path.to_string()]);
481            cache.put(file_path, h, all_issues.clone());
482        }
483
484        AnalysisResult {
485            issues: all_issues,
486            type_envs: HashMap::new(),
487            symbols,
488        }
489    }
490
491    /// Analyze a PHP source string without a real file path.
492    /// Useful for tests and LSP single-file mode.
493    pub fn analyze_source(source: &str) -> AnalysisResult {
494        use crate::collector::DefinitionCollector;
495        let analyzer = ProjectAnalyzer::new();
496        analyzer.load_stubs();
497        let file: Arc<str> = Arc::from("<source>");
498        let arena = bumpalo::Bump::new();
499        let result = php_rs_parser::parse(&arena, source);
500        let mut all_issues = Vec::new();
501        let collector =
502            DefinitionCollector::new(&analyzer.codebase, file.clone(), source, &result.source_map);
503        all_issues.extend(collector.collect(&result.program));
504        analyzer.codebase.finalize();
505        let mut type_envs = std::collections::HashMap::new();
506        let mut all_symbols = Vec::new();
507        all_issues.extend(analyzer.analyze_bodies_typed(
508            &result.program,
509            file.clone(),
510            source,
511            &result.source_map,
512            &mut type_envs,
513            &mut all_symbols,
514        ));
515        AnalysisResult {
516            issues: all_issues,
517            type_envs,
518            symbols: all_symbols,
519        }
520    }
521
522    /// Pass 2: walk all function/method bodies in one file, return issues, and
523    /// write inferred return types back to the codebase.
524    fn analyze_bodies<'arena, 'src>(
525        &self,
526        program: &php_ast::ast::Program<'arena, 'src>,
527        file: Arc<str>,
528        source: &str,
529        source_map: &php_rs_parser::source_map::SourceMap,
530    ) -> (Vec<mir_issues::Issue>, Vec<crate::symbol::ResolvedSymbol>) {
531        use php_ast::ast::StmtKind;
532
533        let mut all_issues = Vec::new();
534        let mut all_symbols = Vec::new();
535
536        for stmt in program.stmts.iter() {
537            match &stmt.kind {
538                StmtKind::Function(decl) => {
539                    self.analyze_fn_decl(
540                        decl,
541                        &file,
542                        source,
543                        source_map,
544                        &mut all_issues,
545                        &mut all_symbols,
546                    );
547                }
548                StmtKind::Class(decl) => {
549                    self.analyze_class_decl(
550                        decl,
551                        &file,
552                        source,
553                        source_map,
554                        &mut all_issues,
555                        &mut all_symbols,
556                    );
557                }
558                StmtKind::Enum(decl) => {
559                    self.analyze_enum_decl(decl, &file, source, source_map, &mut all_issues);
560                }
561                StmtKind::Namespace(ns) => {
562                    if let php_ast::ast::NamespaceBody::Braced(stmts) = &ns.body {
563                        for inner in stmts.iter() {
564                            match &inner.kind {
565                                StmtKind::Function(decl) => {
566                                    self.analyze_fn_decl(
567                                        decl,
568                                        &file,
569                                        source,
570                                        source_map,
571                                        &mut all_issues,
572                                        &mut all_symbols,
573                                    );
574                                }
575                                StmtKind::Class(decl) => {
576                                    self.analyze_class_decl(
577                                        decl,
578                                        &file,
579                                        source,
580                                        source_map,
581                                        &mut all_issues,
582                                        &mut all_symbols,
583                                    );
584                                }
585                                StmtKind::Enum(decl) => {
586                                    self.analyze_enum_decl(
587                                        decl,
588                                        &file,
589                                        source,
590                                        source_map,
591                                        &mut all_issues,
592                                    );
593                                }
594                                _ => {}
595                            }
596                        }
597                    }
598                }
599                _ => {}
600            }
601        }
602
603        (all_issues, all_symbols)
604    }
605
606    /// Analyze a single function declaration body and collect issues + inferred return type.
607    #[allow(clippy::too_many_arguments)]
608    fn analyze_fn_decl<'arena, 'src>(
609        &self,
610        decl: &php_ast::ast::FunctionDecl<'arena, 'src>,
611        file: &Arc<str>,
612        source: &str,
613        source_map: &php_rs_parser::source_map::SourceMap,
614        all_issues: &mut Vec<mir_issues::Issue>,
615        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
616    ) {
617        let fn_name = decl.name;
618        let body = &decl.body;
619        // Check parameter and return type hints for undefined classes.
620        for param in decl.params.iter() {
621            if let Some(hint) = &param.type_hint {
622                check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
623            }
624        }
625        if let Some(hint) = &decl.return_type {
626            check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
627        }
628        use crate::context::Context;
629        use crate::stmt::StatementsAnalyzer;
630        use mir_issues::IssueBuffer;
631
632        // Resolve function name using the file's namespace (handles namespaced functions)
633        let resolved_fn = self.codebase.resolve_class_name(file.as_ref(), fn_name);
634        let func_opt: Option<mir_codebase::storage::FunctionStorage> = self
635            .codebase
636            .functions
637            .get(resolved_fn.as_str())
638            .map(|r| r.clone())
639            .or_else(|| self.codebase.functions.get(fn_name).map(|r| r.clone()))
640            .or_else(|| {
641                self.codebase
642                    .functions
643                    .iter()
644                    .find(|e| e.short_name.as_ref() == fn_name)
645                    .map(|e| e.value().clone())
646            });
647
648        let fqn = func_opt.as_ref().map(|f| f.fqn.clone());
649        // Always use the codebase entry when its params match the AST (same count + names).
650        // This covers the common case and preserves docblock-enriched types.
651        // When names differ (two files define the same unnamespaced function), fall back to
652        // the AST params so param variables are always in scope for this file's body.
653        let (params, return_ty): (Vec<mir_codebase::FnParam>, _) = match &func_opt {
654            Some(f)
655                if f.params.len() == decl.params.len()
656                    && f.params
657                        .iter()
658                        .zip(decl.params.iter())
659                        .all(|(cp, ap)| cp.name.as_ref() == ap.name) =>
660            {
661                (f.params.clone(), f.return_type.clone())
662            }
663            _ => {
664                let ast_params = decl
665                    .params
666                    .iter()
667                    .map(|p| mir_codebase::FnParam {
668                        name: Arc::from(p.name),
669                        ty: None,
670                        default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
671                        is_variadic: p.variadic,
672                        is_byref: p.by_ref,
673                        is_optional: p.default.is_some() || p.variadic,
674                    })
675                    .collect();
676                (ast_params, None)
677            }
678        };
679
680        let mut ctx = Context::for_function(&params, return_ty, None, None, None, false);
681        let mut buf = IssueBuffer::new();
682        let mut sa = StatementsAnalyzer::new(
683            &self.codebase,
684            file.clone(),
685            source,
686            source_map,
687            &mut buf,
688            all_symbols,
689        );
690        sa.analyze_stmts(body, &mut ctx);
691        let inferred = merge_return_types(&sa.return_types);
692        drop(sa);
693
694        emit_unused_params(&params, &ctx, "", file, all_issues);
695        emit_unused_variables(&ctx, file, all_issues);
696        all_issues.extend(buf.into_issues());
697
698        if let Some(fqn) = fqn {
699            if let Some(mut func) = self.codebase.functions.get_mut(fqn.as_ref()) {
700                func.inferred_return_type = Some(inferred);
701            }
702        }
703    }
704
705    /// Analyze all method bodies on a class declaration and collect issues + inferred return types.
706    #[allow(clippy::too_many_arguments)]
707    fn analyze_class_decl<'arena, 'src>(
708        &self,
709        decl: &php_ast::ast::ClassDecl<'arena, 'src>,
710        file: &Arc<str>,
711        source: &str,
712        source_map: &php_rs_parser::source_map::SourceMap,
713        all_issues: &mut Vec<mir_issues::Issue>,
714        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
715    ) {
716        use crate::context::Context;
717        use crate::stmt::StatementsAnalyzer;
718        use mir_issues::IssueBuffer;
719
720        let class_name = decl.name.unwrap_or("<anonymous>");
721        // Resolve the FQCN using the file's namespace/imports — avoids ambiguity
722        // when multiple classes share the same short name across namespaces.
723        let resolved = self.codebase.resolve_class_name(file.as_ref(), class_name);
724        let fqcn: &str = &resolved;
725        let parent_fqcn = self
726            .codebase
727            .classes
728            .get(fqcn)
729            .and_then(|c| c.parent.clone());
730
731        for member in decl.members.iter() {
732            let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
733                continue;
734            };
735
736            // Check parameter and return type hints for undefined classes (even abstract methods).
737            for param in method.params.iter() {
738                if let Some(hint) = &param.type_hint {
739                    check_type_hint_classes(
740                        hint,
741                        &self.codebase,
742                        file,
743                        source,
744                        source_map,
745                        all_issues,
746                    );
747                }
748            }
749            if let Some(hint) = &method.return_type {
750                check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
751            }
752
753            let Some(body) = &method.body else { continue };
754
755            let method_storage = self.codebase.get_method(fqcn, method.name);
756            let (params, return_ty) = method_storage
757                .as_ref()
758                .map(|m| (m.params.clone(), m.return_type.clone()))
759                .unwrap_or_default();
760
761            let is_ctor = method.name == "__construct";
762            let mut ctx = Context::for_method(
763                &params,
764                return_ty,
765                Some(Arc::from(fqcn)),
766                parent_fqcn.clone(),
767                Some(Arc::from(fqcn)),
768                false,
769                is_ctor,
770            );
771
772            let mut buf = IssueBuffer::new();
773            let mut sa = StatementsAnalyzer::new(
774                &self.codebase,
775                file.clone(),
776                source,
777                source_map,
778                &mut buf,
779                all_symbols,
780            );
781            sa.analyze_stmts(body, &mut ctx);
782            let inferred = merge_return_types(&sa.return_types);
783            drop(sa);
784
785            emit_unused_params(&params, &ctx, method.name, file, all_issues);
786            emit_unused_variables(&ctx, file, all_issues);
787            all_issues.extend(buf.into_issues());
788
789            if let Some(mut cls) = self.codebase.classes.get_mut(fqcn) {
790                if let Some(m) = cls.own_methods.get_mut(method.name) {
791                    m.inferred_return_type = Some(inferred);
792                }
793            }
794        }
795    }
796
797    /// Like `analyze_bodies` but also populates `type_envs` with per-scope type environments.
798    #[allow(clippy::too_many_arguments)]
799    fn analyze_bodies_typed<'arena, 'src>(
800        &self,
801        program: &php_ast::ast::Program<'arena, 'src>,
802        file: Arc<str>,
803        source: &str,
804        source_map: &php_rs_parser::source_map::SourceMap,
805        type_envs: &mut std::collections::HashMap<
806            crate::type_env::ScopeId,
807            crate::type_env::TypeEnv,
808        >,
809        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
810    ) -> Vec<mir_issues::Issue> {
811        use php_ast::ast::StmtKind;
812        let mut all_issues = Vec::new();
813        for stmt in program.stmts.iter() {
814            match &stmt.kind {
815                StmtKind::Function(decl) => {
816                    self.analyze_fn_decl_typed(
817                        decl,
818                        &file,
819                        source,
820                        source_map,
821                        &mut all_issues,
822                        type_envs,
823                        all_symbols,
824                    );
825                }
826                StmtKind::Class(decl) => {
827                    self.analyze_class_decl_typed(
828                        decl,
829                        &file,
830                        source,
831                        source_map,
832                        &mut all_issues,
833                        type_envs,
834                        all_symbols,
835                    );
836                }
837                StmtKind::Enum(decl) => {
838                    self.analyze_enum_decl(decl, &file, source, source_map, &mut all_issues);
839                }
840                StmtKind::Namespace(ns) => {
841                    if let php_ast::ast::NamespaceBody::Braced(stmts) = &ns.body {
842                        for inner in stmts.iter() {
843                            match &inner.kind {
844                                StmtKind::Function(decl) => {
845                                    self.analyze_fn_decl_typed(
846                                        decl,
847                                        &file,
848                                        source,
849                                        source_map,
850                                        &mut all_issues,
851                                        type_envs,
852                                        all_symbols,
853                                    );
854                                }
855                                StmtKind::Class(decl) => {
856                                    self.analyze_class_decl_typed(
857                                        decl,
858                                        &file,
859                                        source,
860                                        source_map,
861                                        &mut all_issues,
862                                        type_envs,
863                                        all_symbols,
864                                    );
865                                }
866                                StmtKind::Enum(decl) => {
867                                    self.analyze_enum_decl(
868                                        decl,
869                                        &file,
870                                        source,
871                                        source_map,
872                                        &mut all_issues,
873                                    );
874                                }
875                                _ => {}
876                            }
877                        }
878                    }
879                }
880                _ => {}
881            }
882        }
883        all_issues
884    }
885
886    /// Like `analyze_fn_decl` but also captures a `TypeEnv` for the function scope.
887    #[allow(clippy::too_many_arguments)]
888    fn analyze_fn_decl_typed<'arena, 'src>(
889        &self,
890        decl: &php_ast::ast::FunctionDecl<'arena, 'src>,
891        file: &Arc<str>,
892        source: &str,
893        source_map: &php_rs_parser::source_map::SourceMap,
894        all_issues: &mut Vec<mir_issues::Issue>,
895        type_envs: &mut std::collections::HashMap<
896            crate::type_env::ScopeId,
897            crate::type_env::TypeEnv,
898        >,
899        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
900    ) {
901        use crate::context::Context;
902        use crate::stmt::StatementsAnalyzer;
903        use mir_issues::IssueBuffer;
904
905        let fn_name = decl.name;
906        let body = &decl.body;
907
908        for param in decl.params.iter() {
909            if let Some(hint) = &param.type_hint {
910                check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
911            }
912        }
913        if let Some(hint) = &decl.return_type {
914            check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
915        }
916
917        let resolved_fn = self.codebase.resolve_class_name(file.as_ref(), fn_name);
918        let func_opt: Option<mir_codebase::storage::FunctionStorage> = self
919            .codebase
920            .functions
921            .get(resolved_fn.as_str())
922            .map(|r| r.clone())
923            .or_else(|| self.codebase.functions.get(fn_name).map(|r| r.clone()))
924            .or_else(|| {
925                self.codebase
926                    .functions
927                    .iter()
928                    .find(|e| e.short_name.as_ref() == fn_name)
929                    .map(|e| e.value().clone())
930            });
931
932        let fqn = func_opt.as_ref().map(|f| f.fqn.clone());
933        let (params, return_ty): (Vec<mir_codebase::FnParam>, _) = match &func_opt {
934            Some(f)
935                if f.params.len() == decl.params.len()
936                    && f.params
937                        .iter()
938                        .zip(decl.params.iter())
939                        .all(|(cp, ap)| cp.name.as_ref() == ap.name) =>
940            {
941                (f.params.clone(), f.return_type.clone())
942            }
943            _ => {
944                let ast_params = decl
945                    .params
946                    .iter()
947                    .map(|p| mir_codebase::FnParam {
948                        name: Arc::from(p.name),
949                        ty: None,
950                        default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
951                        is_variadic: p.variadic,
952                        is_byref: p.by_ref,
953                        is_optional: p.default.is_some() || p.variadic,
954                    })
955                    .collect();
956                (ast_params, None)
957            }
958        };
959
960        let mut ctx = Context::for_function(&params, return_ty, None, None, None, false);
961        let mut buf = IssueBuffer::new();
962        let mut sa = StatementsAnalyzer::new(
963            &self.codebase,
964            file.clone(),
965            source,
966            source_map,
967            &mut buf,
968            all_symbols,
969        );
970        sa.analyze_stmts(body, &mut ctx);
971        let inferred = merge_return_types(&sa.return_types);
972        drop(sa);
973
974        // Capture TypeEnv for this scope
975        let scope_name = fqn.clone().unwrap_or_else(|| Arc::from(fn_name));
976        type_envs.insert(
977            crate::type_env::ScopeId::Function {
978                file: file.clone(),
979                name: scope_name,
980            },
981            crate::type_env::TypeEnv::new(ctx.vars.clone()),
982        );
983
984        emit_unused_params(&params, &ctx, "", file, all_issues);
985        emit_unused_variables(&ctx, file, all_issues);
986        all_issues.extend(buf.into_issues());
987
988        if let Some(fqn) = fqn {
989            if let Some(mut func) = self.codebase.functions.get_mut(fqn.as_ref()) {
990                func.inferred_return_type = Some(inferred);
991            }
992        }
993    }
994
995    /// Like `analyze_class_decl` but also captures a `TypeEnv` per method scope.
996    #[allow(clippy::too_many_arguments)]
997    fn analyze_class_decl_typed<'arena, 'src>(
998        &self,
999        decl: &php_ast::ast::ClassDecl<'arena, 'src>,
1000        file: &Arc<str>,
1001        source: &str,
1002        source_map: &php_rs_parser::source_map::SourceMap,
1003        all_issues: &mut Vec<mir_issues::Issue>,
1004        type_envs: &mut std::collections::HashMap<
1005            crate::type_env::ScopeId,
1006            crate::type_env::TypeEnv,
1007        >,
1008        all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
1009    ) {
1010        use crate::context::Context;
1011        use crate::stmt::StatementsAnalyzer;
1012        use mir_issues::IssueBuffer;
1013
1014        let class_name = decl.name.unwrap_or("<anonymous>");
1015        let resolved = self.codebase.resolve_class_name(file.as_ref(), class_name);
1016        let fqcn: &str = &resolved;
1017        let parent_fqcn = self
1018            .codebase
1019            .classes
1020            .get(fqcn)
1021            .and_then(|c| c.parent.clone());
1022
1023        for member in decl.members.iter() {
1024            let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
1025                continue;
1026            };
1027
1028            for param in method.params.iter() {
1029                if let Some(hint) = &param.type_hint {
1030                    check_type_hint_classes(
1031                        hint,
1032                        &self.codebase,
1033                        file,
1034                        source,
1035                        source_map,
1036                        all_issues,
1037                    );
1038                }
1039            }
1040            if let Some(hint) = &method.return_type {
1041                check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
1042            }
1043
1044            let Some(body) = &method.body else { continue };
1045
1046            let method_storage = self.codebase.get_method(fqcn, method.name);
1047            let (params, return_ty) = method_storage
1048                .as_ref()
1049                .map(|m| (m.params.clone(), m.return_type.clone()))
1050                .unwrap_or_default();
1051
1052            let is_ctor = method.name == "__construct";
1053            let mut ctx = Context::for_method(
1054                &params,
1055                return_ty,
1056                Some(Arc::from(fqcn)),
1057                parent_fqcn.clone(),
1058                Some(Arc::from(fqcn)),
1059                false,
1060                is_ctor,
1061            );
1062
1063            let mut buf = IssueBuffer::new();
1064            let mut sa = StatementsAnalyzer::new(
1065                &self.codebase,
1066                file.clone(),
1067                source,
1068                source_map,
1069                &mut buf,
1070                all_symbols,
1071            );
1072            sa.analyze_stmts(body, &mut ctx);
1073            let inferred = merge_return_types(&sa.return_types);
1074            drop(sa);
1075
1076            // Capture TypeEnv for this method scope
1077            type_envs.insert(
1078                crate::type_env::ScopeId::Method {
1079                    class: Arc::from(fqcn),
1080                    method: Arc::from(method.name),
1081                },
1082                crate::type_env::TypeEnv::new(ctx.vars.clone()),
1083            );
1084
1085            emit_unused_params(&params, &ctx, method.name, file, all_issues);
1086            emit_unused_variables(&ctx, file, all_issues);
1087            all_issues.extend(buf.into_issues());
1088
1089            if let Some(mut cls) = self.codebase.classes.get_mut(fqcn) {
1090                if let Some(m) = cls.own_methods.get_mut(method.name) {
1091                    m.inferred_return_type = Some(inferred);
1092                }
1093            }
1094        }
1095    }
1096
1097    /// Discover all `.php` files under a directory, recursively.
1098    pub fn discover_files(root: &Path) -> Vec<PathBuf> {
1099        if root.is_file() {
1100            return vec![root.to_path_buf()];
1101        }
1102        let mut files = Vec::new();
1103        collect_php_files(root, &mut files);
1104        files
1105    }
1106
1107    /// Pass 1 only: collect type definitions from `paths` into the codebase without
1108    /// analyzing method bodies or emitting issues. Used to load vendor types.
1109    pub fn collect_types_only(&self, paths: &[PathBuf]) {
1110        let file_data: Vec<(Arc<str>, String)> = paths
1111            .par_iter()
1112            .filter_map(|path| {
1113                std::fs::read_to_string(path)
1114                    .ok()
1115                    .map(|src| (Arc::from(path.to_string_lossy().as_ref()), src))
1116            })
1117            .collect();
1118
1119        for (file, src) in &file_data {
1120            let arena = bumpalo::Bump::new();
1121            let result = php_rs_parser::parse(&arena, src);
1122            let collector =
1123                DefinitionCollector::new(&self.codebase, file.clone(), src, &result.source_map);
1124            // Ignore any issues emitted during vendor collection
1125            let _ = collector.collect(&result.program);
1126        }
1127    }
1128
1129    /// Check type hints in enum methods for undefined classes.
1130    #[allow(clippy::too_many_arguments)]
1131    fn analyze_enum_decl<'arena, 'src>(
1132        &self,
1133        decl: &php_ast::ast::EnumDecl<'arena, 'src>,
1134        file: &Arc<str>,
1135        source: &str,
1136        source_map: &php_rs_parser::source_map::SourceMap,
1137        all_issues: &mut Vec<mir_issues::Issue>,
1138    ) {
1139        use php_ast::ast::EnumMemberKind;
1140        for member in decl.members.iter() {
1141            let EnumMemberKind::Method(method) = &member.kind else {
1142                continue;
1143            };
1144            for param in method.params.iter() {
1145                if let Some(hint) = &param.type_hint {
1146                    check_type_hint_classes(
1147                        hint,
1148                        &self.codebase,
1149                        file,
1150                        source,
1151                        source_map,
1152                        all_issues,
1153                    );
1154                }
1155            }
1156            if let Some(hint) = &method.return_type {
1157                check_type_hint_classes(hint, &self.codebase, file, source, source_map, all_issues);
1158            }
1159        }
1160    }
1161}
1162
1163impl Default for ProjectAnalyzer {
1164    fn default() -> Self {
1165        Self::new()
1166    }
1167}
1168
1169// ---------------------------------------------------------------------------
1170// UTF-16 offset conversion utility
1171// ---------------------------------------------------------------------------
1172
1173/// Convert a byte offset to a UTF-16 column on a given line.
1174/// Returns (line, col_utf16) where col is 0-based UTF-16 code unit count.
1175fn offset_to_line_col_utf16(
1176    source: &str,
1177    offset: u32,
1178    source_map: &php_rs_parser::source_map::SourceMap,
1179) -> (u32, u16) {
1180    let lc = source_map.offset_to_line_col(offset);
1181    let line = lc.line + 1;
1182
1183    // Find the start of the line containing this offset
1184    let byte_offset = offset as usize;
1185    let line_start_byte = if byte_offset == 0 {
1186        0
1187    } else {
1188        // Find the position after the last newline before this offset
1189        source[..byte_offset]
1190            .rfind('\n')
1191            .map(|p| p + 1)
1192            .unwrap_or(0)
1193    };
1194
1195    // Count UTF-16 code units from line start to the offset
1196    let col_utf16 = source[line_start_byte..byte_offset]
1197        .chars()
1198        .map(|c| c.len_utf16() as u16)
1199        .sum();
1200
1201    (line, col_utf16)
1202}
1203
1204// ---------------------------------------------------------------------------
1205// Type-hint class existence checker
1206// ---------------------------------------------------------------------------
1207
1208/// Walk a `TypeHint` AST node and emit `UndefinedClass` for any named class
1209/// that does not exist in the codebase.  Skips PHP built-in type keywords.
1210fn check_type_hint_classes<'arena, 'src>(
1211    hint: &php_ast::ast::TypeHint<'arena, 'src>,
1212    codebase: &Codebase,
1213    file: &Arc<str>,
1214    source: &str,
1215    source_map: &php_rs_parser::source_map::SourceMap,
1216    issues: &mut Vec<mir_issues::Issue>,
1217) {
1218    use php_ast::ast::TypeHintKind;
1219    match &hint.kind {
1220        TypeHintKind::Named(name) => {
1221            let name_str = crate::parser::name_to_string(name);
1222            // Skip built-in pseudo-types that are not real classes.
1223            if is_pseudo_type(&name_str) {
1224                return;
1225            }
1226            let resolved = codebase.resolve_class_name(file.as_ref(), &name_str);
1227            if !codebase.type_exists(&resolved) {
1228                let (line, col_start) =
1229                    offset_to_line_col_utf16(source, hint.span.start, source_map);
1230                let col_end = if hint.span.start < hint.span.end {
1231                    let (_end_line, end_col) =
1232                        offset_to_line_col_utf16(source, hint.span.end, source_map);
1233                    end_col
1234                } else {
1235                    col_start
1236                };
1237                issues.push(
1238                    mir_issues::Issue::new(
1239                        mir_issues::IssueKind::UndefinedClass { name: resolved },
1240                        mir_issues::Location {
1241                            file: file.clone(),
1242                            line,
1243                            col_start,
1244                            col_end: col_end.max(col_start + 1),
1245                        },
1246                    )
1247                    .with_snippet(crate::parser::span_text(source, hint.span).unwrap_or_default()),
1248                );
1249            }
1250        }
1251        TypeHintKind::Nullable(inner) => {
1252            check_type_hint_classes(inner, codebase, file, source, source_map, issues);
1253        }
1254        TypeHintKind::Union(parts) | TypeHintKind::Intersection(parts) => {
1255            for part in parts.iter() {
1256                check_type_hint_classes(part, codebase, file, source, source_map, issues);
1257            }
1258        }
1259        TypeHintKind::Keyword(_, _) => {} // built-in keyword, always valid
1260    }
1261}
1262
1263/// Returns true for names that are PHP pseudo-types / special identifiers, not
1264/// real classes.
1265fn is_pseudo_type(name: &str) -> bool {
1266    matches!(
1267        name.to_lowercase().as_str(),
1268        "self"
1269            | "static"
1270            | "parent"
1271            | "null"
1272            | "true"
1273            | "false"
1274            | "never"
1275            | "void"
1276            | "mixed"
1277            | "object"
1278            | "callable"
1279            | "iterable"
1280    )
1281}
1282
1283/// Magic methods whose parameters are passed by the PHP runtime, not user call sites.
1284const MAGIC_METHODS_WITH_RUNTIME_PARAMS: &[&str] = &[
1285    "__get",
1286    "__set",
1287    "__call",
1288    "__callStatic",
1289    "__isset",
1290    "__unset",
1291];
1292
1293/// Emit `UnusedParam` issues for params that were never read in `ctx`.
1294/// Skips magic methods whose parameters are passed by the PHP runtime.
1295fn emit_unused_params(
1296    params: &[mir_codebase::FnParam],
1297    ctx: &crate::context::Context,
1298    method_name: &str,
1299    file: &Arc<str>,
1300    issues: &mut Vec<mir_issues::Issue>,
1301) {
1302    if MAGIC_METHODS_WITH_RUNTIME_PARAMS.contains(&method_name) {
1303        return;
1304    }
1305    for p in params {
1306        let name = p.name.as_ref().trim_start_matches('$');
1307        if !ctx.read_vars.contains(name) {
1308            issues.push(
1309                mir_issues::Issue::new(
1310                    mir_issues::IssueKind::UnusedParam {
1311                        name: name.to_string(),
1312                    },
1313                    mir_issues::Location {
1314                        file: file.clone(),
1315                        line: 1,
1316                        col_start: 0,
1317                        col_end: 0,
1318                    },
1319                )
1320                .with_snippet(format!("${}", name)),
1321            );
1322        }
1323    }
1324}
1325
1326fn emit_unused_variables(
1327    ctx: &crate::context::Context,
1328    file: &Arc<str>,
1329    issues: &mut Vec<mir_issues::Issue>,
1330) {
1331    // Superglobals are always "used" — skip them
1332    const SUPERGLOBALS: &[&str] = &[
1333        "_SERVER", "_GET", "_POST", "_REQUEST", "_SESSION", "_COOKIE", "_FILES", "_ENV", "GLOBALS",
1334    ];
1335    for name in &ctx.assigned_vars {
1336        if ctx.param_names.contains(name) {
1337            continue;
1338        }
1339        if SUPERGLOBALS.contains(&name.as_str()) {
1340            continue;
1341        }
1342        if name.starts_with('_') {
1343            continue;
1344        }
1345        if !ctx.read_vars.contains(name) {
1346            issues.push(mir_issues::Issue::new(
1347                mir_issues::IssueKind::UnusedVariable { name: name.clone() },
1348                mir_issues::Location {
1349                    file: file.clone(),
1350                    line: 1,
1351                    col_start: 0,
1352                    col_end: 0,
1353                },
1354            ));
1355        }
1356    }
1357}
1358
1359/// Merge a list of return types into a single `Union`.
1360/// Returns `void` if the list is empty.
1361pub fn merge_return_types(return_types: &[Union]) -> Union {
1362    if return_types.is_empty() {
1363        return Union::single(mir_types::Atomic::TVoid);
1364    }
1365    return_types
1366        .iter()
1367        .fold(Union::empty(), |acc, t| Union::merge(&acc, t))
1368}
1369
1370pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1371    if let Ok(entries) = std::fs::read_dir(dir) {
1372        for entry in entries.flatten() {
1373            // Skip symlinks — they can form cycles (e.g. .pnpm-store)
1374            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1375                continue;
1376            }
1377            let path = entry.path();
1378            if path.is_dir() {
1379                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1380                if matches!(
1381                    name,
1382                    "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1383                ) {
1384                    continue;
1385                }
1386                collect_php_files(&path, out);
1387            } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1388                out.push(path);
1389            }
1390        }
1391    }
1392}
1393
1394// ---------------------------------------------------------------------------
1395// AnalysisResult
1396// ---------------------------------------------------------------------------
1397
1398// ---------------------------------------------------------------------------
1399// build_reverse_deps
1400// ---------------------------------------------------------------------------
1401
1402/// Build a reverse dependency graph from the codebase after Pass 1.
1403///
1404/// Returns a map: `defining_file → {files that depend on it}`.
1405///
1406/// Dependency edges captured (all derivable from Pass 1 data):
1407/// - `use` imports  (`file_imports`)
1408/// - `extends` / `implements` / trait `use` from `ClassStorage`
1409fn build_reverse_deps(codebase: &Codebase) -> HashMap<String, HashSet<String>> {
1410    let mut reverse: HashMap<String, HashSet<String>> = HashMap::new();
1411
1412    // Helper: record edge "defining_file → dependent_file"
1413    let mut add_edge = |symbol: &str, dependent_file: &str| {
1414        if let Some(defining_file) = codebase.symbol_to_file.get(symbol) {
1415            let def = defining_file.as_ref().to_string();
1416            if def != dependent_file {
1417                reverse
1418                    .entry(def)
1419                    .or_default()
1420                    .insert(dependent_file.to_string());
1421            }
1422        }
1423    };
1424
1425    // use-import edges
1426    for entry in codebase.file_imports.iter() {
1427        let file = entry.key().as_ref().to_string();
1428        for fqcn in entry.value().values() {
1429            add_edge(fqcn, &file);
1430        }
1431    }
1432
1433    // extends / implements / trait edges from ClassStorage
1434    for entry in codebase.classes.iter() {
1435        let defining = {
1436            let fqcn = entry.key().as_ref();
1437            codebase
1438                .symbol_to_file
1439                .get(fqcn)
1440                .map(|f| f.as_ref().to_string())
1441        };
1442        let Some(file) = defining else { continue };
1443
1444        let cls = entry.value();
1445        if let Some(ref parent) = cls.parent {
1446            add_edge(parent.as_ref(), &file);
1447        }
1448        for iface in &cls.interfaces {
1449            add_edge(iface.as_ref(), &file);
1450        }
1451        for tr in &cls.traits {
1452            add_edge(tr.as_ref(), &file);
1453        }
1454    }
1455
1456    reverse
1457}
1458
1459// ---------------------------------------------------------------------------
1460
1461pub struct AnalysisResult {
1462    pub issues: Vec<Issue>,
1463    pub type_envs: std::collections::HashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1464    /// Per-expression resolved symbols from Pass 2.
1465    pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1466}
1467
1468impl AnalysisResult {
1469    pub fn error_count(&self) -> usize {
1470        self.issues
1471            .iter()
1472            .filter(|i| i.severity == mir_issues::Severity::Error)
1473            .count()
1474    }
1475
1476    pub fn warning_count(&self) -> usize {
1477        self.issues
1478            .iter()
1479            .filter(|i| i.severity == mir_issues::Severity::Warning)
1480            .count()
1481    }
1482}