Skip to main content

mir_analyzer/
class.rs

1/// Class analyzer — validates class definitions after codebase finalization.
2///
3/// Checks performed (all codebase-level, no AST required):
4///   - Concrete class implements all abstract parent methods
5///   - Concrete class implements all interface methods
6///   - Overriding method does not reduce visibility
7///   - Overriding method return type is covariant with parent
8///   - Overriding method does not override a final method
9///   - Class does not extend a final class
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use mir_codebase::storage::{MethodStorage, Visibility};
14use mir_codebase::Codebase;
15use mir_issues::{Issue, IssueKind, Location};
16
17// ---------------------------------------------------------------------------
18// ClassAnalyzer
19// ---------------------------------------------------------------------------
20
21pub struct ClassAnalyzer<'a> {
22    codebase: &'a Codebase,
23    /// Only report issues for classes defined in these files (empty = all files).
24    analyzed_files: HashSet<Arc<str>>,
25    /// Source text keyed by file path, used to extract snippets for class-level issues.
26    sources: HashMap<Arc<str>, &'a str>,
27}
28
29impl<'a> ClassAnalyzer<'a> {
30    pub fn new(codebase: &'a Codebase) -> Self {
31        Self {
32            codebase,
33            analyzed_files: HashSet::new(),
34            sources: HashMap::new(),
35        }
36    }
37
38    pub fn with_files(
39        codebase: &'a Codebase,
40        files: HashSet<Arc<str>>,
41        file_data: &'a [(Arc<str>, String)],
42    ) -> Self {
43        let sources: HashMap<Arc<str>, &'a str> = file_data
44            .iter()
45            .map(|(f, s)| (f.clone(), s.as_str()))
46            .collect();
47        Self {
48            codebase,
49            analyzed_files: files,
50            sources,
51        }
52    }
53
54    /// Run all class-level checks and return every discovered issue.
55    pub fn analyze_all(&self) -> Vec<Issue> {
56        let mut issues = Vec::new();
57
58        let class_keys: Vec<Arc<str>> = self
59            .codebase
60            .classes
61            .iter()
62            .map(|e| e.key().clone())
63            .collect();
64
65        for fqcn in &class_keys {
66            let cls = match self.codebase.classes.get(fqcn.as_ref()) {
67                Some(c) => c,
68                None => continue,
69            };
70
71            // Skip classes from vendor / stub files — only check user-analyzed files
72            if !self.analyzed_files.is_empty() {
73                let in_analyzed = cls
74                    .location
75                    .as_ref()
76                    .map(|loc| self.analyzed_files.contains(&loc.file))
77                    .unwrap_or(false);
78                if !in_analyzed {
79                    continue;
80                }
81            }
82
83            // ---- 1. Final-class extension check / deprecated parent check ------
84            if let Some(parent_fqcn) = &cls.parent {
85                if let Some(parent) = self.codebase.classes.get(parent_fqcn.as_ref()) {
86                    if parent.is_final {
87                        let loc = issue_location(
88                            cls.location.as_ref(),
89                            fqcn,
90                            cls.location
91                                .as_ref()
92                                .and_then(|l| self.sources.get(&l.file).copied()),
93                        );
94                        let mut issue = Issue::new(
95                            IssueKind::FinalClassExtended {
96                                parent: parent_fqcn.to_string(),
97                                child: fqcn.to_string(),
98                            },
99                            loc,
100                        );
101                        if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
102                        {
103                            issue = issue.with_snippet(snippet);
104                        }
105                        issues.push(issue);
106                    }
107                    if let Some(msg) = parent.deprecated.clone() {
108                        let loc = issue_location(
109                            cls.location.as_ref(),
110                            fqcn,
111                            cls.location
112                                .as_ref()
113                                .and_then(|l| self.sources.get(&l.file).copied()),
114                        );
115                        let mut issue = Issue::new(
116                            IssueKind::DeprecatedClass {
117                                name: parent_fqcn.to_string(),
118                                message: Some(msg).filter(|m| !m.is_empty()),
119                            },
120                            loc,
121                        );
122                        if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
123                        {
124                            issue = issue.with_snippet(snippet);
125                        }
126                        issues.push(issue);
127                    }
128                }
129            }
130
131            // Skip abstract classes for "must implement" checks
132            if cls.is_abstract {
133                // Still check override compatibility for abstract classes
134                self.check_overrides(&cls, &mut issues);
135                continue;
136            }
137
138            // ---- 2. Abstract parent methods must be implemented ----------------
139            self.check_abstract_methods_implemented(&cls, &mut issues);
140
141            // ---- 3. Interface methods must be implemented ----------------------
142            self.check_interface_methods_implemented(&cls, &mut issues);
143
144            // ---- 4. Method override compatibility ------------------------------
145            self.check_overrides(&cls, &mut issues);
146        }
147
148        // ---- 5. Circular inheritance detection --------------------------------
149        self.check_circular_class_inheritance(&mut issues);
150        self.check_circular_interface_inheritance(&mut issues);
151
152        issues
153    }
154
155    // -----------------------------------------------------------------------
156    // Check: all abstract methods from ancestor chain are implemented
157    // -----------------------------------------------------------------------
158
159    fn check_abstract_methods_implemented(
160        &self,
161        cls: &mir_codebase::storage::ClassStorage,
162        issues: &mut Vec<Issue>,
163    ) {
164        let fqcn = &cls.fqcn;
165
166        // Walk every ancestor class and collect abstract methods
167        for ancestor_fqcn in &cls.all_parents {
168            // Collect abstract method names first, then drop the DashMap guard before
169            // calling get_method (which re-enters the same DashMap).
170            let abstract_methods: Vec<Arc<str>> = {
171                let Some(ancestor) = self.codebase.classes.get(ancestor_fqcn.as_ref()) else {
172                    continue;
173                };
174                ancestor
175                    .own_methods
176                    .iter()
177                    .filter(|(_, m)| m.is_abstract)
178                    .map(|(_, m)| m.name.clone())
179                    .collect()
180            };
181
182            for method_name in abstract_methods {
183                // Check if the concrete class (or any closer ancestor) provides it
184                if self
185                    .codebase
186                    .get_method(fqcn.as_ref(), method_name.as_ref())
187                    .map(|m| !m.is_abstract)
188                    .unwrap_or(false)
189                {
190                    continue; // implemented
191                }
192
193                let loc = issue_location(
194                    cls.location.as_ref(),
195                    fqcn,
196                    cls.location
197                        .as_ref()
198                        .and_then(|l| self.sources.get(&l.file).copied()),
199                );
200                let mut issue = Issue::new(
201                    IssueKind::UnimplementedAbstractMethod {
202                        class: fqcn.to_string(),
203                        method: method_name.to_string(),
204                    },
205                    loc,
206                );
207                if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
208                    issue = issue.with_snippet(snippet);
209                }
210                issues.push(issue);
211            }
212        }
213    }
214
215    // -----------------------------------------------------------------------
216    // Check: all interface methods are implemented
217    // -----------------------------------------------------------------------
218
219    fn check_interface_methods_implemented(
220        &self,
221        cls: &mir_codebase::storage::ClassStorage,
222        issues: &mut Vec<Issue>,
223    ) {
224        let fqcn = &cls.fqcn;
225
226        // Collect all interfaces (direct + from ancestors)
227        let all_ifaces: Vec<Arc<str>> = cls
228            .all_parents
229            .iter()
230            .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
231            .cloned()
232            .collect();
233
234        for iface_fqcn in &all_ifaces {
235            // Collect method names first, then drop the interface guard before calling
236            // get_method (which re-enters self.codebase.interfaces when walking ancestors).
237            let method_names: Vec<Arc<str>> =
238                match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
239                    Some(iface) => iface.own_methods.values().map(|m| m.name.clone()).collect(),
240                    None => continue,
241                };
242
243            for method_name in method_names {
244                // PHP method names are case-insensitive; normalize before lookup so that
245                // a hand-written stub key like "jsonSerialize" matches the collector's
246                // lowercased key "jsonserialize" stored in own_methods.
247                let method_name_lower = method_name.to_lowercase();
248                // Check if the class provides a concrete implementation
249                let implemented = self
250                    .codebase
251                    .get_method(fqcn.as_ref(), &method_name_lower)
252                    .map(|m| !m.is_abstract)
253                    .unwrap_or(false);
254
255                if !implemented {
256                    let loc = issue_location(
257                        cls.location.as_ref(),
258                        fqcn,
259                        cls.location
260                            .as_ref()
261                            .and_then(|l| self.sources.get(&l.file).copied()),
262                    );
263                    let mut issue = Issue::new(
264                        IssueKind::UnimplementedInterfaceMethod {
265                            class: fqcn.to_string(),
266                            interface: iface_fqcn.to_string(),
267                            method: method_name.to_string(),
268                        },
269                        loc,
270                    );
271                    if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
272                        issue = issue.with_snippet(snippet);
273                    }
274                    issues.push(issue);
275                }
276            }
277        }
278    }
279
280    // -----------------------------------------------------------------------
281    // Check: override compatibility
282    // -----------------------------------------------------------------------
283
284    fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
285        let fqcn = &cls.fqcn;
286
287        for (method_name, own_method) in &cls.own_methods {
288            // PHP does not enforce constructor signature compatibility
289            if method_name.as_ref() == "__construct" {
290                continue;
291            }
292
293            // Find parent definition (if any) — search ancestor chain
294            let parent_method = self.find_parent_method(cls, method_name.as_ref());
295
296            let parent = match parent_method {
297                Some(m) => m,
298                None => continue, // not an override
299            };
300
301            let loc = issue_location(
302                own_method.location.as_ref(),
303                fqcn,
304                own_method
305                    .location
306                    .as_ref()
307                    .and_then(|l| self.sources.get(&l.file).copied()),
308            );
309
310            // ---- a. Cannot override a final method -------------------------
311            if parent.is_final {
312                let mut issue = Issue::new(
313                    IssueKind::FinalMethodOverridden {
314                        class: fqcn.to_string(),
315                        method: method_name.to_string(),
316                        parent: parent.fqcn.to_string(),
317                    },
318                    loc.clone(),
319                );
320                if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
321                {
322                    issue = issue.with_snippet(snippet);
323                }
324                issues.push(issue);
325            }
326
327            // ---- b. Visibility must not be reduced -------------------------
328            if visibility_reduced(own_method.visibility, parent.visibility) {
329                let mut issue = Issue::new(
330                    IssueKind::OverriddenMethodAccess {
331                        class: fqcn.to_string(),
332                        method: method_name.to_string(),
333                    },
334                    loc.clone(),
335                );
336                if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
337                {
338                    issue = issue.with_snippet(snippet);
339                }
340                issues.push(issue);
341            }
342
343            // ---- c. Return type must be covariant --------------------------
344            // Only check when both sides have an explicit return type.
345            // Skip when:
346            //   - Parent type is from a docblock (PHP doesn't enforce docblock override compat)
347            //   - Either type is mixed
348            //   - Parent type contains a template param
349            if let (Some(child_ret), Some(parent_ret)) =
350                (&own_method.return_type, &parent.return_type)
351            {
352                let parent_from_docblock = parent_ret.from_docblock;
353                let involves_named_objects = self.type_has_named_objects(child_ret)
354                    || self.type_has_named_objects(parent_ret);
355                let involves_self_static = self.type_has_self_or_static(child_ret)
356                    || self.type_has_self_or_static(parent_ret);
357
358                if !parent_from_docblock
359                    && !parent_ret.is_mixed()
360                    && !child_ret.is_mixed()
361                    && !self.return_type_has_template(parent_ret)
362                {
363                    let child_file = own_method
364                        .location
365                        .as_ref()
366                        .map(|l| l.file.as_ref())
367                        .unwrap_or("");
368
369                    let compatible = if (involves_named_objects || involves_self_static)
370                        && self.type_has_only_object_atoms(child_ret)
371                        && self.type_has_only_object_atoms(parent_ret)
372                    {
373                        crate::stmt::named_object_return_compatible(
374                            child_ret,
375                            parent_ret,
376                            self.codebase,
377                            child_file,
378                        )
379                    } else if involves_named_objects || involves_self_static {
380                        true // mixed scalar+object union — skip (G5 gap)
381                    } else {
382                        child_ret.is_subtype_of_simple(parent_ret)
383                    };
384
385                    if !compatible {
386                        issues.push(
387                            Issue::new(
388                                IssueKind::MethodSignatureMismatch {
389                                    class: fqcn.to_string(),
390                                    method: method_name.to_string(),
391                                    detail: format!(
392                                        "return type '{child_ret}' is not a subtype of parent '{parent_ret}'"
393                                    ),
394                                },
395                                loc.clone(),
396                            )
397                            .with_snippet(method_name.to_string()),
398                        );
399                    }
400                }
401            }
402
403            // ---- d. Required param count must not increase -----------------
404            let parent_required = parent
405                .params
406                .iter()
407                .filter(|p| !p.is_optional && !p.is_variadic)
408                .count();
409            let child_required = own_method
410                .params
411                .iter()
412                .filter(|p| !p.is_optional && !p.is_variadic)
413                .count();
414
415            if child_required > parent_required {
416                issues.push(
417                    Issue::new(
418                        IssueKind::MethodSignatureMismatch {
419                            class: fqcn.to_string(),
420                            method: method_name.to_string(),
421                            detail: format!(
422                                "overriding method requires {child_required} argument(s) but parent requires {parent_required}"
423                            ),
424                        },
425                        loc.clone(),
426                    )
427                    .with_snippet(method_name.to_string()),
428                );
429            }
430
431            // ---- e. Param types must not be narrowed (contravariance) --------
432            // For each positional param present in both parent and child:
433            //   parent_param_type must be a subtype of child_param_type.
434            //   (Child may widen; it must not narrow.)
435            // Skip when:
436            //   - Either side has no type hint
437            //   - Either type is mixed
438            //   - Either type contains a named object (needs codebase for inheritance check)
439            //   - Either type contains TSelf/TStaticObject
440            //   - Either type contains a template param
441            let shared_len = parent.params.len().min(own_method.params.len());
442            for i in 0..shared_len {
443                let parent_param = &parent.params[i];
444                let child_param = &own_method.params[i];
445
446                let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
447                    (Some(p), Some(c)) => (p, c),
448                    _ => continue,
449                };
450
451                if parent_ty.is_mixed()
452                    || child_ty.is_mixed()
453                    || self.type_has_named_objects(parent_ty)
454                    || self.type_has_named_objects(child_ty)
455                    || self.type_has_self_or_static(parent_ty)
456                    || self.type_has_self_or_static(child_ty)
457                    || self.return_type_has_template(parent_ty)
458                    || self.return_type_has_template(child_ty)
459                {
460                    continue;
461                }
462
463                // Contravariance: parent_ty must be subtype of child_ty.
464                // If not, child has narrowed the param type.
465                if !parent_ty.is_subtype_of_simple(child_ty) {
466                    issues.push(
467                        Issue::new(
468                            IssueKind::MethodSignatureMismatch {
469                                class: fqcn.to_string(),
470                                method: method_name.to_string(),
471                                detail: format!(
472                                    "parameter ${} type '{}' is narrower than parent type '{}'",
473                                    child_param.name, child_ty, parent_ty
474                                ),
475                            },
476                            loc.clone(),
477                        )
478                        .with_snippet(method_name.to_string()),
479                    );
480                    break; // one issue per method is enough
481                }
482            }
483        }
484    }
485
486    // -----------------------------------------------------------------------
487    // Helpers
488    // -----------------------------------------------------------------------
489
490    /// Returns true if the type contains template params or class-strings with unknown types.
491    /// Used to suppress MethodSignatureMismatch on generic parent return types.
492    /// Checks recursively into array key/value types.
493    fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
494        use mir_types::Atomic;
495        ty.types.iter().any(|atomic| match atomic {
496            Atomic::TTemplateParam { .. } => true,
497            Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
498            Atomic::TNamedObject { fqcn, type_params } => {
499                // Bare name with no namespace separator is likely a template param
500                (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
501                    // Also check if any type params are templates
502                    || type_params.iter().any(|tp| self.return_type_has_template(tp))
503            }
504            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
505                self.return_type_has_template(key) || self.return_type_has_template(value)
506            }
507            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
508                self.return_type_has_template(value)
509            }
510            _ => false,
511        })
512    }
513
514    /// Returns true if the type contains any named-object atomics (TNamedObject)
515    /// at any level (including inside array key/value types).
516    /// Named-object subtyping requires codebase inheritance lookup, so we skip
517    /// the simple structural check for these.
518    fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
519        use mir_types::Atomic;
520        ty.types.iter().any(|a| match a {
521            Atomic::TNamedObject { .. } => true,
522            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
523                self.type_has_named_objects(key) || self.type_has_named_objects(value)
524            }
525            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
526                self.type_has_named_objects(value)
527            }
528            _ => false,
529        })
530    }
531
532    /// Returns true if the type contains TSelf or TStaticObject (late-static types).
533    /// These are always considered compatible with their bound class type.
534    fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
535        use mir_types::Atomic;
536        ty.types
537            .iter()
538            .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
539    }
540
541    /// Returns true if every atom in the union is handled by `named_object_return_compatible`:
542    /// object types (named/self/static/parent), null, void, never, and class-string variants.
543    /// Unions that also contain scalar atoms (int, string, …) are not fully handled there
544    /// and must fall back to the skip path (G5 gap).
545    fn type_has_only_object_atoms(&self, ty: &mir_types::Union) -> bool {
546        use mir_types::Atomic;
547        ty.types.iter().all(|a| {
548            matches!(
549                a,
550                Atomic::TNamedObject { .. }
551                    | Atomic::TSelf { .. }
552                    | Atomic::TStaticObject { .. }
553                    | Atomic::TParent { .. }
554                    | Atomic::TNull
555                    | Atomic::TVoid
556                    | Atomic::TNever
557                    | Atomic::TClassString(_)
558            )
559        })
560    }
561
562    /// Find a method with the given name in the closest ancestor (not the class itself).
563    fn find_parent_method(
564        &self,
565        cls: &mir_codebase::storage::ClassStorage,
566        method_name: &str,
567    ) -> Option<Arc<MethodStorage>> {
568        // Walk all_parents in order (closest ancestor first)
569        for ancestor_fqcn in &cls.all_parents {
570            if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
571                if let Some(m) = ancestor_cls.own_methods.get(method_name) {
572                    return Some(Arc::clone(m));
573                }
574            } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
575                if let Some(m) = iface.own_methods.get(method_name) {
576                    return Some(Arc::clone(m));
577                }
578            }
579        }
580        None
581    }
582
583    // -----------------------------------------------------------------------
584    // Check: circular class inheritance (class A extends B extends A)
585    // -----------------------------------------------------------------------
586
587    fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
588        let mut globally_done: HashSet<String> = HashSet::new();
589
590        let mut class_keys: Vec<Arc<str>> = self
591            .codebase
592            .classes
593            .iter()
594            .map(|e| e.key().clone())
595            .collect();
596        class_keys.sort();
597
598        for start_fqcn in &class_keys {
599            if globally_done.contains(start_fqcn.as_ref()) {
600                continue;
601            }
602
603            // Walk the parent chain, tracking order for cycle reporting.
604            let mut chain: Vec<Arc<str>> = Vec::new();
605            let mut chain_set: HashSet<String> = HashSet::new();
606            let mut current: Arc<str> = start_fqcn.clone();
607
608            loop {
609                if globally_done.contains(current.as_ref()) {
610                    // Known safe — stop here.
611                    for node in &chain {
612                        globally_done.insert(node.to_string());
613                    }
614                    break;
615                }
616                if !chain_set.insert(current.to_string()) {
617                    // current is already in chain → cycle detected.
618                    let cycle_start = chain
619                        .iter()
620                        .position(|p| p.as_ref() == current.as_ref())
621                        .unwrap_or(0);
622                    let cycle_nodes = &chain[cycle_start..];
623
624                    // Report on the lexicographically last class in the cycle
625                    // that belongs to an analyzed file (or any if filter is empty).
626                    let offender = cycle_nodes
627                        .iter()
628                        .filter(|n| self.class_in_analyzed_files(n))
629                        .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
630
631                    if let Some(offender) = offender {
632                        let cls = self.codebase.classes.get(offender.as_ref());
633                        let loc = issue_location(
634                            cls.as_ref().and_then(|c| c.location.as_ref()),
635                            offender,
636                            cls.as_ref()
637                                .and_then(|c| c.location.as_ref())
638                                .and_then(|l| self.sources.get(&l.file).copied()),
639                        );
640                        let mut issue = Issue::new(
641                            IssueKind::CircularInheritance {
642                                class: offender.to_string(),
643                            },
644                            loc,
645                        );
646                        if let Some(snippet) = extract_snippet(
647                            cls.as_ref().and_then(|c| c.location.as_ref()),
648                            &self.sources,
649                        ) {
650                            issue = issue.with_snippet(snippet);
651                        }
652                        issues.push(issue);
653                    }
654
655                    for node in &chain {
656                        globally_done.insert(node.to_string());
657                    }
658                    break;
659                }
660
661                chain.push(current.clone());
662
663                let parent = self
664                    .codebase
665                    .classes
666                    .get(current.as_ref())
667                    .and_then(|c| c.parent.clone());
668
669                match parent {
670                    Some(p) => current = p,
671                    None => {
672                        for node in &chain {
673                            globally_done.insert(node.to_string());
674                        }
675                        break;
676                    }
677                }
678            }
679        }
680    }
681
682    // -----------------------------------------------------------------------
683    // Check: circular interface inheritance (interface I1 extends I2 extends I1)
684    // -----------------------------------------------------------------------
685
686    fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
687        let mut globally_done: HashSet<String> = HashSet::new();
688
689        let mut iface_keys: Vec<Arc<str>> = self
690            .codebase
691            .interfaces
692            .iter()
693            .map(|e| e.key().clone())
694            .collect();
695        iface_keys.sort();
696
697        for start_fqcn in &iface_keys {
698            if globally_done.contains(start_fqcn.as_ref()) {
699                continue;
700            }
701            let mut in_stack: Vec<Arc<str>> = Vec::new();
702            let mut stack_set: HashSet<String> = HashSet::new();
703            self.dfs_interface_cycle(
704                start_fqcn.clone(),
705                &mut in_stack,
706                &mut stack_set,
707                &mut globally_done,
708                issues,
709            );
710        }
711    }
712
713    fn dfs_interface_cycle(
714        &self,
715        fqcn: Arc<str>,
716        in_stack: &mut Vec<Arc<str>>,
717        stack_set: &mut HashSet<String>,
718        globally_done: &mut HashSet<String>,
719        issues: &mut Vec<Issue>,
720    ) {
721        if globally_done.contains(fqcn.as_ref()) {
722            return;
723        }
724        if stack_set.contains(fqcn.as_ref()) {
725            // Cycle: find cycle nodes from in_stack.
726            let cycle_start = in_stack
727                .iter()
728                .position(|p| p.as_ref() == fqcn.as_ref())
729                .unwrap_or(0);
730            let cycle_nodes = &in_stack[cycle_start..];
731
732            let offender = cycle_nodes
733                .iter()
734                .filter(|n| self.iface_in_analyzed_files(n))
735                .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
736
737            if let Some(offender) = offender {
738                let iface = self.codebase.interfaces.get(offender.as_ref());
739                let loc = issue_location(
740                    iface.as_ref().and_then(|i| i.location.as_ref()),
741                    offender,
742                    iface
743                        .as_ref()
744                        .and_then(|i| i.location.as_ref())
745                        .and_then(|l| self.sources.get(&l.file).copied()),
746                );
747                let mut issue = Issue::new(
748                    IssueKind::CircularInheritance {
749                        class: offender.to_string(),
750                    },
751                    loc,
752                );
753                if let Some(snippet) = extract_snippet(
754                    iface.as_ref().and_then(|i| i.location.as_ref()),
755                    &self.sources,
756                ) {
757                    issue = issue.with_snippet(snippet);
758                }
759                issues.push(issue);
760            }
761            return;
762        }
763
764        stack_set.insert(fqcn.to_string());
765        in_stack.push(fqcn.clone());
766
767        let extends = self
768            .codebase
769            .interfaces
770            .get(fqcn.as_ref())
771            .map(|i| i.extends.clone())
772            .unwrap_or_default();
773
774        for parent in extends {
775            self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
776        }
777
778        in_stack.pop();
779        stack_set.remove(fqcn.as_ref());
780        globally_done.insert(fqcn.to_string());
781    }
782
783    fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
784        if self.analyzed_files.is_empty() {
785            return true;
786        }
787        self.codebase
788            .classes
789            .get(fqcn.as_ref())
790            .map(|c| {
791                c.location
792                    .as_ref()
793                    .map(|loc| self.analyzed_files.contains(&loc.file))
794                    .unwrap_or(false)
795            })
796            .unwrap_or(false)
797    }
798
799    fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
800        if self.analyzed_files.is_empty() {
801            return true;
802        }
803        self.codebase
804            .interfaces
805            .get(fqcn.as_ref())
806            .map(|i| {
807                i.location
808                    .as_ref()
809                    .map(|loc| self.analyzed_files.contains(&loc.file))
810                    .unwrap_or(false)
811            })
812            .unwrap_or(false)
813    }
814}
815
816/// Returns true if `child_vis` is strictly less visible than `parent_vis`.
817fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
818    // Public > Protected > Private (in terms of access)
819    // Reducing means going from more visible to less visible.
820    matches!(
821        (parent_vis, child_vis),
822        (Visibility::Public, Visibility::Protected)
823            | (Visibility::Public, Visibility::Private)
824            | (Visibility::Protected, Visibility::Private)
825    )
826}
827
828/// Build an issue location from the stored codebase Location.
829/// Falls back to a dummy location using the FQCN as the file path when no
830/// Location is stored.
831fn issue_location(
832    storage_loc: Option<&mir_codebase::storage::Location>,
833    fqcn: &Arc<str>,
834    _source: Option<&str>,
835) -> Location {
836    match storage_loc {
837        Some(loc) => Location {
838            file: loc.file.clone(),
839            line: loc.line,
840            line_end: loc.line_end,
841            col_start: loc.col_start,
842            col_end: loc.col_end,
843        },
844        None => Location {
845            file: fqcn.clone(),
846            line: 1,
847            line_end: 1,
848            col_start: 0,
849            col_end: 0,
850        },
851    }
852}
853
854/// Extract the first line of source text covered by `storage_loc` as a snippet.
855fn extract_snippet(
856    storage_loc: Option<&mir_codebase::storage::Location>,
857    sources: &HashMap<Arc<str>, &str>,
858) -> Option<String> {
859    let loc = storage_loc?;
860    let src = *sources.get(&loc.file)?;
861    // Walk to the 1-based start line (loc.line is already 1-based).
862    let line_idx = loc.line.saturating_sub(1) as usize;
863    let line_text = src.lines().nth(line_idx)?;
864    Some(line_text.trim().to_string())
865}