Skip to main content

mir_analyzer/
call.rs

1/// Call analyzer — resolves function/method calls, checks arguments, returns
2/// the inferred return type.
3use std::sync::Arc;
4
5use php_ast::ast::{ExprKind, FunctionCallExpr, MethodCallExpr, StaticMethodCallExpr};
6use php_ast::Span;
7
8use mir_codebase::storage::{FnParam, MethodStorage, Visibility};
9use mir_issues::{IssueKind, Severity};
10use mir_types::{Atomic, Union};
11
12use crate::context::Context;
13use crate::expr::ExpressionAnalyzer;
14use crate::generic::{build_class_bindings, check_template_bounds, infer_template_bindings};
15use crate::symbol::SymbolKind;
16use crate::taint::{classify_sink, is_expr_tainted, SinkKind};
17
18// ---------------------------------------------------------------------------
19// CallAnalyzer
20// ---------------------------------------------------------------------------
21
22pub struct CallAnalyzer;
23
24impl CallAnalyzer {
25    // -----------------------------------------------------------------------
26    // Function calls: name(args)
27    // -----------------------------------------------------------------------
28
29    pub fn analyze_function_call<'a, 'arena, 'src>(
30        ea: &mut ExpressionAnalyzer<'a>,
31        call: &FunctionCallExpr<'arena, 'src>,
32        ctx: &mut Context,
33        span: Span,
34    ) -> Union {
35        // Resolve function name first (needed for sink check before arg eval)
36        let fn_name = match &call.name.kind {
37            ExprKind::Identifier(name) => (*name).to_string(),
38            _ => {
39                // dynamic call — evaluate name and args for read tracking
40                ea.analyze(call.name, ctx);
41                for arg in call.args.iter() {
42                    ea.analyze(&arg.value, ctx);
43                }
44                return Union::mixed();
45            }
46        };
47
48        // Taint sink check (M19): before evaluating args so we can inspect raw exprs
49        if let Some(sink_kind) = classify_sink(&fn_name) {
50            for arg in call.args.iter() {
51                if is_expr_tainted(&arg.value, ctx) {
52                    let issue_kind = match sink_kind {
53                        SinkKind::Html => IssueKind::TaintedHtml,
54                        SinkKind::Sql => IssueKind::TaintedSql,
55                        SinkKind::Shell => IssueKind::TaintedShell,
56                    };
57                    ea.emit(issue_kind, Severity::Error, span);
58                    break; // one report per call site is enough
59                }
60            }
61        }
62
63        // Resolve the function name: try namespace-qualified first, then global fallback.
64        // PHP resolves `foo()` as `\App\Ns\foo` first, then `\foo` if not found.
65        // A leading `\` means explicit global namespace (e.g. `\assert` = global `assert`).
66        let fn_name = fn_name
67            .strip_prefix('\\')
68            .map(|s: &str| s.to_string())
69            .unwrap_or(fn_name);
70        let resolved_fn_name: String = {
71            let qualified = ea.codebase.resolve_class_name(&ea.file, &fn_name);
72            if ea.codebase.functions.contains_key(qualified.as_str()) {
73                qualified
74            } else if ea.codebase.functions.contains_key(fn_name.as_str()) {
75                fn_name.clone()
76            } else {
77                // Keep the qualified name so the "unknown" error is informative
78                qualified
79            }
80        };
81
82        // Pre-mark by-reference parameter variables as defined BEFORE evaluating args,
83        // so that passing an uninitialized variable to a by-ref param does not emit
84        // UndefinedVariable (the function will initialize it).
85        if let Some(func) = ea.codebase.functions.get(resolved_fn_name.as_str()) {
86            for (i, param) in func.params.iter().enumerate() {
87                if param.is_byref {
88                    if param.is_variadic {
89                        // Variadic by-ref: mark every remaining argument (e.g. sscanf output vars).
90                        for arg in call.args.iter().skip(i) {
91                            if let ExprKind::Variable(name) = &arg.value.kind {
92                                let var_name = name.as_str().trim_start_matches('$');
93                                if !ctx.var_is_defined(var_name) {
94                                    ctx.set_var(var_name, Union::mixed());
95                                }
96                            }
97                        }
98                    } else if let Some(arg) = call.args.get(i) {
99                        if let ExprKind::Variable(name) = &arg.value.kind {
100                            let var_name = name.as_str().trim_start_matches('$');
101                            if !ctx.var_is_defined(var_name) {
102                                ctx.set_var(var_name, Union::mixed());
103                            }
104                        }
105                    }
106                }
107            }
108        }
109
110        // Evaluate all arguments
111        let arg_types: Vec<Union> = call
112            .args
113            .iter()
114            .map(|arg| {
115                let ty = ea.analyze(&arg.value, ctx);
116                if arg.unpack {
117                    spread_element_type(&ty)
118                } else {
119                    ty
120                }
121            })
122            .collect();
123
124        // Look up user-defined function in codebase
125        if let Some(func) = ea.codebase.functions.get(resolved_fn_name.as_str()) {
126            // Use the name expression span, not the full call span, so the LSP
127            // highlights only the function identifier.
128            let name_span = call.name.span;
129            ea.codebase.mark_function_referenced_at(
130                &func.fqn,
131                ea.file.clone(),
132                name_span.start,
133                name_span.end,
134            );
135            let is_deprecated = func.is_deprecated;
136            let params = func.params.clone();
137            let template_params = func.template_params.clone();
138            let return_ty_raw = func
139                .effective_return_type()
140                .cloned()
141                .unwrap_or_else(Union::mixed);
142
143            // Emit DeprecatedCall if the function is marked @deprecated
144            if is_deprecated {
145                ea.emit(
146                    IssueKind::DeprecatedCall {
147                        name: resolved_fn_name.clone(),
148                    },
149                    Severity::Info,
150                    span,
151                );
152            }
153
154            check_args(
155                ea,
156                CheckArgsParams {
157                    fn_name: &fn_name,
158                    params: &params,
159                    arg_types: &arg_types,
160                    arg_spans: &call.args.iter().map(|a| a.span).collect::<Vec<_>>(),
161                    arg_names: &call
162                        .args
163                        .iter()
164                        .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
165                        .collect::<Vec<_>>(),
166                    call_span: span,
167                    has_spread: call.args.iter().any(|a| a.unpack),
168                },
169            );
170
171            // Also ensure by-ref vars are defined after the call (for post-call usage)
172            for (i, param) in params.iter().enumerate() {
173                if param.is_byref {
174                    if param.is_variadic {
175                        for arg in call.args.iter().skip(i) {
176                            if let ExprKind::Variable(name) = &arg.value.kind {
177                                let var_name = name.as_str().trim_start_matches('$');
178                                ctx.set_var(var_name, Union::mixed());
179                            }
180                        }
181                    } else if let Some(arg) = call.args.get(i) {
182                        if let ExprKind::Variable(name) = &arg.value.kind {
183                            let var_name = name.as_str().trim_start_matches('$');
184                            ctx.set_var(var_name, Union::mixed());
185                        }
186                    }
187                }
188            }
189
190            // Generic: substitute template params in return type
191            let return_ty = if !template_params.is_empty() {
192                let bindings = infer_template_bindings(&template_params, &params, &arg_types);
193                // Check bounds
194                for (name, inferred, bound) in check_template_bounds(&bindings, &template_params) {
195                    ea.emit(
196                        IssueKind::InvalidTemplateParam {
197                            name: name.to_string(),
198                            expected_bound: format!("{}", bound),
199                            actual: format!("{}", inferred),
200                        },
201                        Severity::Error,
202                        span,
203                    );
204                }
205                return_ty_raw.substitute_templates(&bindings)
206            } else {
207                return_ty_raw
208            };
209
210            ea.record_symbol(
211                call.name.span,
212                SymbolKind::FunctionCall(func.fqn.clone()),
213                return_ty.clone(),
214            );
215            return return_ty;
216        }
217
218        // Unknown function — report the unqualified name to keep the message readable
219        ea.emit(
220            IssueKind::UndefinedFunction { name: fn_name },
221            Severity::Error,
222            span,
223        );
224        Union::mixed()
225    }
226
227    // -----------------------------------------------------------------------
228    // Method calls: $obj->method(args)
229    // -----------------------------------------------------------------------
230
231    pub fn analyze_method_call<'a, 'arena, 'src>(
232        ea: &mut ExpressionAnalyzer<'a>,
233        call: &MethodCallExpr<'arena, 'src>,
234        ctx: &mut Context,
235        span: Span,
236        nullsafe: bool,
237    ) -> Union {
238        let obj_ty = ea.analyze(call.object, ctx);
239
240        let method_name = match &call.method.kind {
241            ExprKind::Identifier(name) | ExprKind::Variable(name) => name.as_str(),
242            _ => return Union::mixed(),
243        };
244
245        // Always analyze arguments — even when the receiver is null/mixed and we
246        // return early — so that variable reads inside args are tracked (read_vars)
247        // and side effects (taint, etc.) are recorded.
248        let arg_types: Vec<Union> = call
249            .args
250            .iter()
251            .map(|arg| {
252                let ty = ea.analyze(&arg.value, ctx);
253                if arg.unpack {
254                    spread_element_type(&ty)
255                } else {
256                    ty
257                }
258            })
259            .collect();
260
261        let arg_spans: Vec<Span> = call.args.iter().map(|a| a.span).collect();
262
263        // Null checks
264        if obj_ty.contains(|t| matches!(t, Atomic::TNull)) {
265            if nullsafe {
266                // ?-> is fine, just returns null on null receiver
267            } else if obj_ty.is_single() {
268                ea.emit(
269                    IssueKind::NullMethodCall {
270                        method: method_name.to_string(),
271                    },
272                    Severity::Error,
273                    span,
274                );
275                return Union::mixed();
276            } else {
277                ea.emit(
278                    IssueKind::PossiblyNullMethodCall {
279                        method: method_name.to_string(),
280                    },
281                    Severity::Info,
282                    span,
283                );
284            }
285        }
286
287        // Mixed receiver
288        if obj_ty.is_mixed() {
289            ea.emit(
290                IssueKind::MixedMethodCall {
291                    method: method_name.to_string(),
292                },
293                Severity::Info,
294                span,
295            );
296            return Union::mixed();
297        }
298
299        let receiver = obj_ty.remove_null();
300        let mut result = Union::empty();
301
302        for atomic in &receiver.types {
303            match atomic {
304                Atomic::TNamedObject {
305                    fqcn,
306                    type_params: receiver_type_params,
307                } => {
308                    // Resolve short names to FQCN — docblock types may not be fully qualified.
309                    let fqcn_resolved = ea.codebase.resolve_class_name(&ea.file, fqcn);
310                    let fqcn = &std::sync::Arc::from(fqcn_resolved.as_str());
311                    if let Some(method) = ea.codebase.get_method(fqcn, method_name) {
312                        // Record reference for dead-code detection (M18).
313                        // Use call.method.span (the identifier only), not the full call
314                        // span, so the LSP highlights just the method name.
315                        ea.codebase.mark_method_referenced_at(
316                            fqcn,
317                            method_name,
318                            ea.file.clone(),
319                            call.method.span.start,
320                            call.method.span.end,
321                        );
322                        // Emit DeprecatedMethodCall if the method is marked @deprecated
323                        if method.is_deprecated {
324                            ea.emit(
325                                IssueKind::DeprecatedMethodCall {
326                                    class: fqcn.to_string(),
327                                    method: method_name.to_string(),
328                                },
329                                Severity::Info,
330                                span,
331                            );
332                        }
333                        // Visibility check (simplified — only checks private from outside)
334                        check_method_visibility(ea, &method, ctx, span);
335
336                        // Arg type check
337                        let arg_names: Vec<Option<String>> = call
338                            .args
339                            .iter()
340                            .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
341                            .collect();
342                        check_args(
343                            ea,
344                            CheckArgsParams {
345                                fn_name: method_name,
346                                params: &method.params,
347                                arg_types: &arg_types,
348                                arg_spans: &arg_spans,
349                                arg_names: &arg_names,
350                                call_span: span,
351                                has_spread: call.args.iter().any(|a| a.unpack),
352                            },
353                        );
354
355                        let ret_raw = method
356                            .effective_return_type()
357                            .cloned()
358                            .unwrap_or_else(Union::mixed);
359                        // Bind `static` return type to the actual receiver class (LSB).
360                        let ret_raw = substitute_static_in_return(ret_raw, fqcn);
361
362                        // Build class-level bindings from receiver's concrete type params (e.g. Collection<User> → T=User)
363                        let class_tps = ea.codebase.get_class_template_params(fqcn);
364                        let mut bindings = build_class_bindings(&class_tps, receiver_type_params);
365
366                        // Extend with method-level bindings; warn on name collision (method shadows class template)
367                        if !method.template_params.is_empty() {
368                            let method_bindings = infer_template_bindings(
369                                &method.template_params,
370                                &method.params,
371                                &arg_types,
372                            );
373                            for key in method_bindings.keys() {
374                                if bindings.contains_key(key) {
375                                    ea.emit(
376                                        IssueKind::ShadowedTemplateParam {
377                                            name: key.to_string(),
378                                        },
379                                        Severity::Info,
380                                        span,
381                                    );
382                                }
383                            }
384                            bindings.extend(method_bindings);
385                            for (name, inferred, bound) in
386                                check_template_bounds(&bindings, &method.template_params)
387                            {
388                                ea.emit(
389                                    IssueKind::InvalidTemplateParam {
390                                        name: name.to_string(),
391                                        expected_bound: format!("{}", bound),
392                                        actual: format!("{}", inferred),
393                                    },
394                                    Severity::Error,
395                                    span,
396                                );
397                            }
398                        }
399
400                        let ret = if !bindings.is_empty() {
401                            ret_raw.substitute_templates(&bindings)
402                        } else {
403                            ret_raw
404                        };
405                        result = Union::merge(&result, &ret);
406                    } else if ea.codebase.type_exists(fqcn)
407                        && !ea.codebase.has_unknown_ancestor(fqcn)
408                    {
409                        // Class is known AND has no unscanned ancestors → genuine UndefinedMethod.
410                        // If the class has an external/unscanned parent (e.g. a PHPUnit TestCase),
411                        // the method might be inherited from that parent; skip to avoid false positives.
412                        // Classes with __call handle any method dynamically — suppress.
413                        // Interface types: method may exist on the concrete implementation — suppress
414                        // (UndefinedInterfaceMethod is not emitted at default error level).
415                        let is_interface = ea.codebase.interfaces.contains_key(fqcn.as_ref());
416                        let is_abstract = ea.codebase.is_abstract_class(fqcn.as_ref());
417                        if is_interface
418                            || is_abstract
419                            || ea.codebase.get_method(fqcn, "__call").is_some()
420                        {
421                            result = Union::merge(&result, &Union::mixed());
422                        } else {
423                            ea.emit(
424                                IssueKind::UndefinedMethod {
425                                    class: fqcn.to_string(),
426                                    method: method_name.to_string(),
427                                },
428                                Severity::Error,
429                                span,
430                            );
431                            result = Union::merge(&result, &Union::mixed());
432                        }
433                    } else {
434                        result = Union::merge(&result, &Union::mixed());
435                    }
436                }
437                Atomic::TSelf { fqcn }
438                | Atomic::TStaticObject { fqcn }
439                | Atomic::TParent { fqcn } => {
440                    let receiver_type_params: &[mir_types::Union] = &[];
441                    // Resolve short names to FQCN — docblock types may not be fully qualified.
442                    let fqcn_resolved = ea.codebase.resolve_class_name(&ea.file, fqcn);
443                    let fqcn = &std::sync::Arc::from(fqcn_resolved.as_str());
444                    if let Some(method) = ea.codebase.get_method(fqcn, method_name) {
445                        // Record reference for dead-code detection (M18).
446                        // Use call.method.span (the identifier only), not the full call
447                        // span, so the LSP highlights just the method name.
448                        ea.codebase.mark_method_referenced_at(
449                            fqcn,
450                            method_name,
451                            ea.file.clone(),
452                            call.method.span.start,
453                            call.method.span.end,
454                        );
455                        // Emit DeprecatedMethodCall if the method is marked @deprecated
456                        if method.is_deprecated {
457                            ea.emit(
458                                IssueKind::DeprecatedMethodCall {
459                                    class: fqcn.to_string(),
460                                    method: method_name.to_string(),
461                                },
462                                Severity::Info,
463                                span,
464                            );
465                        }
466                        // Visibility check (simplified — only checks private from outside)
467                        check_method_visibility(ea, &method, ctx, span);
468
469                        // Arg type check
470                        let arg_names: Vec<Option<String>> = call
471                            .args
472                            .iter()
473                            .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
474                            .collect();
475                        check_args(
476                            ea,
477                            CheckArgsParams {
478                                fn_name: method_name,
479                                params: &method.params,
480                                arg_types: &arg_types,
481                                arg_spans: &arg_spans,
482                                arg_names: &arg_names,
483                                call_span: span,
484                                has_spread: call.args.iter().any(|a| a.unpack),
485                            },
486                        );
487
488                        let ret_raw = method
489                            .effective_return_type()
490                            .cloned()
491                            .unwrap_or_else(Union::mixed);
492                        // Bind `static` return type to the actual receiver class (LSB).
493                        let ret_raw = substitute_static_in_return(ret_raw, fqcn);
494
495                        // Build class-level bindings from receiver's concrete type params (e.g. Collection<User> → T=User)
496                        let class_tps = ea.codebase.get_class_template_params(fqcn);
497                        let mut bindings = build_class_bindings(&class_tps, receiver_type_params);
498
499                        // Extend with method-level bindings; warn on name collision (method shadows class template)
500                        if !method.template_params.is_empty() {
501                            let method_bindings = infer_template_bindings(
502                                &method.template_params,
503                                &method.params,
504                                &arg_types,
505                            );
506                            for key in method_bindings.keys() {
507                                if bindings.contains_key(key) {
508                                    ea.emit(
509                                        IssueKind::ShadowedTemplateParam {
510                                            name: key.to_string(),
511                                        },
512                                        Severity::Info,
513                                        span,
514                                    );
515                                }
516                            }
517                            bindings.extend(method_bindings);
518                            for (name, inferred, bound) in
519                                check_template_bounds(&bindings, &method.template_params)
520                            {
521                                ea.emit(
522                                    IssueKind::InvalidTemplateParam {
523                                        name: name.to_string(),
524                                        expected_bound: format!("{}", bound),
525                                        actual: format!("{}", inferred),
526                                    },
527                                    Severity::Error,
528                                    span,
529                                );
530                            }
531                        }
532
533                        let ret = if !bindings.is_empty() {
534                            ret_raw.substitute_templates(&bindings)
535                        } else {
536                            ret_raw
537                        };
538                        result = Union::merge(&result, &ret);
539                    } else if ea.codebase.type_exists(fqcn)
540                        && !ea.codebase.has_unknown_ancestor(fqcn)
541                    {
542                        // Class is known AND has no unscanned ancestors → genuine UndefinedMethod.
543                        // If the class has an external/unscanned parent (e.g. a PHPUnit TestCase),
544                        // the method might be inherited from that parent; skip to avoid false positives.
545                        // Classes with __call handle any method dynamically — suppress.
546                        // Interface types: method may exist on the concrete implementation — suppress
547                        // (UndefinedInterfaceMethod is not emitted at default error level).
548                        let is_interface = ea.codebase.interfaces.contains_key(fqcn.as_ref());
549                        let is_abstract = ea.codebase.is_abstract_class(fqcn.as_ref());
550                        if is_interface
551                            || is_abstract
552                            || ea.codebase.get_method(fqcn, "__call").is_some()
553                        {
554                            result = Union::merge(&result, &Union::mixed());
555                        } else {
556                            ea.emit(
557                                IssueKind::UndefinedMethod {
558                                    class: fqcn.to_string(),
559                                    method: method_name.to_string(),
560                                },
561                                Severity::Error,
562                                span,
563                            );
564                            result = Union::merge(&result, &Union::mixed());
565                        }
566                    } else {
567                        result = Union::merge(&result, &Union::mixed());
568                    }
569                }
570                Atomic::TObject => {
571                    result = Union::merge(&result, &Union::mixed());
572                }
573                // Template type parameters (e.g. `T` in `@template T`) are unbound at
574                // analysis time — we cannot know which methods the concrete type will have,
575                // so we must not emit UndefinedMethod here. Treat as mixed and move on.
576                Atomic::TTemplateParam { .. } => {
577                    result = Union::merge(&result, &Union::mixed());
578                }
579                _ => {
580                    result = Union::merge(&result, &Union::mixed());
581                }
582            }
583        }
584
585        if nullsafe && obj_ty.is_nullable() {
586            result.add_type(Atomic::TNull);
587        }
588
589        let final_ty = if result.is_empty() {
590            Union::mixed()
591        } else {
592            result
593        };
594        // Record method call symbol using the first named object in the receiver.
595        // Use call.method.span (the identifier only), not the full call span, so
596        // the LSP highlights just the method name.
597        for atomic in &obj_ty.types {
598            if let Atomic::TNamedObject { fqcn, .. } = atomic {
599                ea.record_symbol(
600                    call.method.span,
601                    SymbolKind::MethodCall {
602                        class: fqcn.clone(),
603                        method: Arc::from(method_name),
604                    },
605                    final_ty.clone(),
606                );
607                break;
608            }
609        }
610        final_ty
611    }
612
613    // -----------------------------------------------------------------------
614    // Static method calls: ClassName::method(args)
615    // -----------------------------------------------------------------------
616
617    pub fn analyze_static_method_call<'a, 'arena, 'src>(
618        ea: &mut ExpressionAnalyzer<'a>,
619        call: &StaticMethodCallExpr<'arena, 'src>,
620        ctx: &mut Context,
621        span: Span,
622    ) -> Union {
623        let method_name = match &call.method.kind {
624            ExprKind::Identifier(name) | ExprKind::Variable(name) => name.as_str(),
625            _ => return Union::mixed(),
626        };
627
628        let fqcn = match &call.class.kind {
629            ExprKind::Identifier(name) => ea.codebase.resolve_class_name(&ea.file, name.as_ref()),
630            _ => return Union::mixed(),
631        };
632
633        let fqcn = resolve_static_class(&fqcn, ctx);
634
635        let arg_types: Vec<Union> = call
636            .args
637            .iter()
638            .map(|arg| {
639                let ty = ea.analyze(&arg.value, ctx);
640                if arg.unpack {
641                    spread_element_type(&ty)
642                } else {
643                    ty
644                }
645            })
646            .collect();
647        let arg_spans: Vec<Span> = call.args.iter().map(|a| a.span).collect();
648
649        if let Some(method) = ea.codebase.get_method(&fqcn, method_name) {
650            // Compute the method name span: class.span covers the class identifier,
651            // then "::" is 2 bytes, then the method name follows.
652            let method_start = call.class.span.end + 2;
653            let method_end = method_start + method_name.len() as u32;
654            ea.codebase.mark_method_referenced_at(
655                &fqcn,
656                method_name,
657                ea.file.clone(),
658                method_start,
659                method_end,
660            );
661            // Emit DeprecatedMethodCall if the method is marked @deprecated
662            if method.is_deprecated {
663                ea.emit(
664                    IssueKind::DeprecatedMethodCall {
665                        class: fqcn.clone(),
666                        method: method_name.to_string(),
667                    },
668                    Severity::Info,
669                    span,
670                );
671            }
672            let arg_names: Vec<Option<String>> = call
673                .args
674                .iter()
675                .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
676                .collect();
677            check_args(
678                ea,
679                CheckArgsParams {
680                    fn_name: method_name,
681                    params: &method.params,
682                    arg_types: &arg_types,
683                    arg_spans: &arg_spans,
684                    arg_names: &arg_names,
685                    call_span: span,
686                    has_spread: call.args.iter().any(|a| a.unpack),
687                },
688            );
689            let ret_raw = method
690                .effective_return_type()
691                .cloned()
692                .unwrap_or_else(Union::mixed);
693            let fqcn_arc: std::sync::Arc<str> = Arc::from(fqcn.as_str());
694            let ret = substitute_static_in_return(ret_raw, &fqcn_arc);
695            let method_span = Span::new(method_start, method_end);
696            ea.record_symbol(
697                method_span,
698                SymbolKind::StaticCall {
699                    class: fqcn_arc,
700                    method: Arc::from(method_name),
701                },
702                ret.clone(),
703            );
704            ret
705        } else if ea.codebase.type_exists(&fqcn) && !ea.codebase.has_unknown_ancestor(&fqcn) {
706            // Class is known AND has no unscanned ancestors → genuine UndefinedMethod.
707            // Classes with __call handle any method dynamically — suppress.
708            // Interface: concrete impl may have the method — suppress at default error level.
709            let is_interface = ea.codebase.interfaces.contains_key(fqcn.as_str());
710            let is_abstract = ea.codebase.is_abstract_class(&fqcn);
711            if is_interface || is_abstract || ea.codebase.get_method(&fqcn, "__call").is_some() {
712                Union::mixed()
713            } else {
714                ea.emit(
715                    IssueKind::UndefinedMethod {
716                        class: fqcn,
717                        method: method_name.to_string(),
718                    },
719                    Severity::Error,
720                    span,
721                );
722                Union::mixed()
723            }
724        } else {
725            // Unknown/external class or class with unscanned ancestor — do not emit false positive
726            Union::mixed()
727        }
728    }
729}
730
731// ---------------------------------------------------------------------------
732// Public helper for constructor argument checking (used by expr.rs)
733// ---------------------------------------------------------------------------
734
735pub struct CheckArgsParams<'a> {
736    pub fn_name: &'a str,
737    pub params: &'a [FnParam],
738    pub arg_types: &'a [Union],
739    pub arg_spans: &'a [Span],
740    pub arg_names: &'a [Option<String>],
741    pub call_span: Span,
742    pub has_spread: bool,
743}
744
745pub fn check_constructor_args(
746    ea: &mut ExpressionAnalyzer<'_>,
747    class_name: &str,
748    p: CheckArgsParams<'_>,
749) {
750    let ctor_name = format!("{}::__construct", class_name);
751    check_args(
752        ea,
753        CheckArgsParams {
754            fn_name: &ctor_name,
755            ..p
756        },
757    );
758}
759
760// ---------------------------------------------------------------------------
761// Argument type checking
762// ---------------------------------------------------------------------------
763
764fn check_args(ea: &mut ExpressionAnalyzer<'_>, p: CheckArgsParams<'_>) {
765    let CheckArgsParams {
766        fn_name,
767        params,
768        arg_types,
769        arg_spans,
770        arg_names,
771        call_span,
772        has_spread,
773    } = p;
774    // Build a remapped (param_index → (arg_type, arg_span)) map that handles
775    // named arguments (PHP 8.0+).
776    let has_named = arg_names.iter().any(|n| n.is_some());
777
778    // param_to_arg maps param index → (Union, Span)
779    let mut param_to_arg: Vec<Option<(Union, Span)>> = vec![None; params.len()];
780
781    if has_named {
782        let mut positional = 0usize;
783        for (i, (ty, span)) in arg_types.iter().zip(arg_spans.iter()).enumerate() {
784            if let Some(Some(name)) = arg_names.get(i) {
785                // Named arg: find the param by name
786                if let Some(pi) = params.iter().position(|p| p.name.as_ref() == name.as_str()) {
787                    param_to_arg[pi] = Some((ty.clone(), *span));
788                }
789            } else {
790                // Positional arg: fill the next unfilled slot
791                while positional < params.len() && param_to_arg[positional].is_some() {
792                    positional += 1;
793                }
794                if positional < params.len() {
795                    param_to_arg[positional] = Some((ty.clone(), *span));
796                    positional += 1;
797                }
798            }
799        }
800    } else {
801        // Pure positional — fast path
802        for (i, (ty, span)) in arg_types.iter().zip(arg_spans.iter()).enumerate() {
803            if i < params.len() {
804                param_to_arg[i] = Some((ty.clone(), *span));
805            }
806        }
807    }
808
809    let required_count = params
810        .iter()
811        .filter(|p| !p.is_optional && !p.is_variadic)
812        .count();
813    let provided_count = if params.iter().any(|p| p.is_variadic) {
814        arg_types.len()
815    } else {
816        arg_types.len().min(params.len())
817    };
818
819    if provided_count < required_count && !has_spread {
820        ea.emit(
821            IssueKind::InvalidArgument {
822                param: format!("#{}", provided_count + 1),
823                fn_name: fn_name.to_string(),
824                expected: format!("{} argument(s)", required_count),
825                actual: format!("{} provided", provided_count),
826            },
827            Severity::Error,
828            call_span,
829        );
830        return;
831    }
832
833    for (i, (param, slot)) in params.iter().zip(param_to_arg.iter()).enumerate() {
834        let (arg_ty, arg_span) = match slot {
835            Some(pair) => pair,
836            None => continue, // optional param not supplied
837        };
838        let arg_span = *arg_span;
839        let _ = i;
840
841        if let Some(raw_param_ty) = &param.ty {
842            // For variadic params annotated as list<T>, each argument should match T, not list<T>.
843            let param_ty_owned;
844            let param_ty: &Union = if param.is_variadic {
845                if let Some(elem_ty) = raw_param_ty.types.iter().find_map(|a| match a {
846                    Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
847                        Some(*value.clone())
848                    }
849                    _ => None,
850                }) {
851                    param_ty_owned = elem_ty;
852                    &param_ty_owned
853                } else {
854                    raw_param_ty
855                }
856            } else {
857                raw_param_ty
858            };
859            // Null check: param is not nullable but arg could be null
860            if !param_ty.is_nullable() && arg_ty.is_nullable() {
861                ea.emit(
862                    IssueKind::PossiblyNullArgument {
863                        param: param.name.to_string(),
864                        fn_name: fn_name.to_string(),
865                    },
866                    Severity::Info,
867                    arg_span,
868                );
869            } else if !param_ty.is_nullable()
870                && arg_ty.contains(|t| matches!(t, Atomic::TNull))
871                && arg_ty.is_single()
872            {
873                ea.emit(
874                    IssueKind::NullArgument {
875                        param: param.name.to_string(),
876                        fn_name: fn_name.to_string(),
877                    },
878                    Severity::Error,
879                    arg_span,
880                );
881            }
882
883            // Type compatibility check: first try the fast structural check, then fall
884            // back to a codebase-aware check that handles class hierarchy and FQCN resolution.
885            if !arg_ty.is_subtype_of_simple(param_ty)
886                && !param_ty.is_mixed()
887                && !arg_ty.is_mixed()
888                && !named_object_subtype(arg_ty, param_ty, ea)
889                && !param_contains_template_or_unknown(param_ty, ea)
890                && !param_contains_template_or_unknown(arg_ty, ea)
891                && !array_list_compatible(arg_ty, param_ty, ea)
892                // Skip when param is more specific than arg (coercion, not hard error):
893                // e.g. string → non-empty-string, int → positive-int, string → string|null
894                && !param_ty.is_subtype_of_simple(arg_ty)
895                // Skip when non-null part of param is a subtype of arg (e.g. non-empty-string|null ← string)
896                && !param_ty.remove_null().is_subtype_of_simple(arg_ty)
897                // Skip when any atomic in param is a subtype of arg (e.g. non-empty-string|list ← string)
898                && !param_ty.types.iter().any(|p| Union::single(p.clone()).is_subtype_of_simple(arg_ty))
899                // Skip when arg is compatible after removing null/false (PossiblyNull/FalseArgument
900                // handles these separately and they may appear in the baseline)
901                && !arg_ty.remove_null().is_subtype_of_simple(param_ty)
902                && !arg_ty.remove_false().is_subtype_of_simple(param_ty)
903                && !named_object_subtype(&arg_ty.remove_null(), param_ty, ea)
904                && !named_object_subtype(&arg_ty.remove_false(), param_ty, ea)
905            {
906                ea.emit(
907                    IssueKind::InvalidArgument {
908                        param: param.name.to_string(),
909                        fn_name: fn_name.to_string(),
910                        expected: format!("{}", param_ty),
911                        actual: format!("{}", arg_ty),
912                    },
913                    Severity::Error,
914                    arg_span,
915                );
916            }
917        }
918    }
919}
920
921/// Returns true if every atomic in `arg` can be assigned to some atomic in `param`
922/// using codebase-aware class hierarchy checks.
923///
924/// Handles two common false-positive cases:
925/// 1. `BackOffBuilder` stored as short name in param vs FQCN in arg → resolve both.
926/// 2. `DateTimeImmutable` extends `DateTimeInterface` → use `extends_or_implements`.
927fn named_object_subtype(arg: &Union, param: &Union, ea: &ExpressionAnalyzer<'_>) -> bool {
928    use mir_types::Atomic;
929    // Every atomic in arg must satisfy the param
930    arg.types.iter().all(|a_atomic| {
931        // Extract FQCN from the arg atomic — handles TNamedObject, TSelf, TStaticObject, TParent
932        let arg_fqcn: &Arc<str> = match a_atomic {
933            Atomic::TNamedObject { fqcn, .. } => fqcn,
934            Atomic::TSelf { fqcn } | Atomic::TStaticObject { fqcn } => {
935                // If the self/static refers to a trait, we can't know the concrete class — skip
936                if ea.codebase.traits.contains_key(fqcn.as_ref()) {
937                    return true;
938                }
939                fqcn
940            }
941            Atomic::TParent { fqcn } => fqcn,
942            // TNever is bottom type — compatible with any param
943            Atomic::TNever => return true,
944            // Closure() types satisfy Closure or callable param
945            Atomic::TClosure { .. } => {
946                return param.types.iter().any(|p| match p {
947                    Atomic::TClosure { .. } | Atomic::TCallable { .. } => true,
948                    Atomic::TNamedObject { fqcn, .. } => fqcn.as_ref() == "Closure",
949                    _ => false,
950                });
951            }
952            // callable satisfies Closure param (not flagged at default error level)
953            Atomic::TCallable { .. } => {
954                return param.types.iter().any(|p| match p {
955                    Atomic::TCallable { .. } | Atomic::TClosure { .. } => true,
956                    Atomic::TNamedObject { fqcn, .. } => fqcn.as_ref() == "Closure",
957                    _ => false,
958                });
959            }
960            // class-string<X> is compatible with class-string<Y> if X extends/implements Y
961            Atomic::TClassString(Some(arg_cls)) => {
962                return param.types.iter().any(|p| match p {
963                    Atomic::TClassString(None) | Atomic::TString => true,
964                    Atomic::TClassString(Some(param_cls)) => {
965                        arg_cls == param_cls
966                            || ea
967                                .codebase
968                                .extends_or_implements(arg_cls.as_ref(), param_cls.as_ref())
969                    }
970                    _ => false,
971                });
972            }
973            // Null satisfies param if param also contains null
974            Atomic::TNull => {
975                return param.types.iter().any(|p| matches!(p, Atomic::TNull));
976            }
977            // False satisfies param if param contains false or bool
978            Atomic::TFalse => {
979                return param
980                    .types
981                    .iter()
982                    .any(|p| matches!(p, Atomic::TFalse | Atomic::TBool));
983            }
984            _ => return false, // non-named-object: not handled here
985        };
986
987        // An object with __invoke satisfies callable|null
988        if param
989            .types
990            .iter()
991            .any(|p| matches!(p, Atomic::TCallable { .. }))
992        {
993            let resolved_arg = ea.codebase.resolve_class_name(&ea.file, arg_fqcn.as_ref());
994            if ea.codebase.get_method(&resolved_arg, "__invoke").is_some()
995                || ea
996                    .codebase
997                    .get_method(arg_fqcn.as_ref(), "__invoke")
998                    .is_some()
999            {
1000                return true;
1001            }
1002        }
1003
1004        param.types.iter().any(|p_atomic| {
1005            let param_fqcn: &Arc<str> = match p_atomic {
1006                Atomic::TNamedObject { fqcn, .. } => fqcn,
1007                Atomic::TSelf { fqcn } => fqcn,
1008                Atomic::TStaticObject { fqcn } => fqcn,
1009                Atomic::TParent { fqcn } => fqcn,
1010                _ => return false,
1011            };
1012            // Resolve param_fqcn in case it's a short name stored from a type hint
1013            let resolved_param = ea
1014                .codebase
1015                .resolve_class_name(&ea.file, param_fqcn.as_ref());
1016            let resolved_arg = ea.codebase.resolve_class_name(&ea.file, arg_fqcn.as_ref());
1017
1018            // Same class — check generic type params with variance
1019            let is_same_class = resolved_param == resolved_arg
1020                || arg_fqcn.as_ref() == resolved_param.as_str()
1021                || resolved_arg == param_fqcn.as_ref();
1022
1023            if is_same_class {
1024                let arg_type_params = match a_atomic {
1025                    Atomic::TNamedObject { type_params, .. } => type_params.as_slice(),
1026                    _ => &[],
1027                };
1028                let param_type_params = match p_atomic {
1029                    Atomic::TNamedObject { type_params, .. } => type_params.as_slice(),
1030                    _ => &[],
1031                };
1032                if !arg_type_params.is_empty() || !param_type_params.is_empty() {
1033                    let class_tps = ea.codebase.get_class_template_params(&resolved_param);
1034                    return generic_type_params_compatible(
1035                        arg_type_params,
1036                        param_type_params,
1037                        &class_tps,
1038                        ea,
1039                    );
1040                }
1041                return true;
1042            }
1043
1044            if ea.codebase.extends_or_implements(arg_fqcn.as_ref(), &resolved_param)
1045                || ea.codebase.extends_or_implements(arg_fqcn.as_ref(), param_fqcn.as_ref())
1046                || ea.codebase.extends_or_implements(&resolved_arg, &resolved_param)
1047                // ArgumentTypeCoercion (suppressed at level 3): param extends arg — arg is
1048                // broader than param. Not a hard error; only flagged at stricter error levels.
1049                || ea.codebase.extends_or_implements(param_fqcn.as_ref(), &resolved_arg)
1050                || ea.codebase.extends_or_implements(param_fqcn.as_ref(), arg_fqcn.as_ref())
1051                || ea.codebase.extends_or_implements(&resolved_param, &resolved_arg)
1052            {
1053                return true;
1054            }
1055
1056            // If arg_fqcn is a short name (no namespace) that didn't resolve through the caller
1057            // file's imports (e.g., return type from a vendor method like `NonNull` from
1058            // `Type::nonNull()`), search codebase for any class with that short_name and check
1059            // if it satisfies the param type.
1060            if !arg_fqcn.contains('\\') && !ea.codebase.type_exists(&resolved_arg) {
1061                for entry in ea.codebase.classes.iter() {
1062                    if entry.value().short_name.as_ref() == arg_fqcn.as_ref() {
1063                        let actual_fqcn = entry.key().clone();
1064                        if ea
1065                            .codebase
1066                            .extends_or_implements(actual_fqcn.as_ref(), &resolved_param)
1067                            || ea
1068                                .codebase
1069                                .extends_or_implements(actual_fqcn.as_ref(), param_fqcn.as_ref())
1070                        {
1071                            return true;
1072                        }
1073                    }
1074                }
1075            }
1076
1077            // If arg_fqcn is an interface, check if any known concrete class both implements
1078            // the interface AND extends/implements the param. This handles cases like
1079            // `ValueNode` (interface) whose implementations all extend `Node` (abstract class).
1080            let iface_key = if ea.codebase.interfaces.contains_key(arg_fqcn.as_ref()) {
1081                Some(arg_fqcn.as_ref())
1082            } else if ea.codebase.interfaces.contains_key(resolved_arg.as_str()) {
1083                Some(resolved_arg.as_str())
1084            } else {
1085                None
1086            };
1087            if let Some(iface_fqcn) = iface_key {
1088                let compatible = ea.codebase.classes.iter().any(|entry| {
1089                    let cls = entry.value();
1090                    cls.all_parents.iter().any(|p| p.as_ref() == iface_fqcn)
1091                        && (ea
1092                            .codebase
1093                            .extends_or_implements(entry.key().as_ref(), param_fqcn.as_ref())
1094                            || ea
1095                                .codebase
1096                                .extends_or_implements(entry.key().as_ref(), &resolved_param))
1097                });
1098                if compatible {
1099                    return true;
1100                }
1101            }
1102
1103            // If arg is a fully-qualified vendor class not in our codebase, we can't verify
1104            // the hierarchy — suppress to avoid false positives on external libraries.
1105            if arg_fqcn.contains('\\')
1106                && !ea.codebase.type_exists(arg_fqcn.as_ref())
1107                && !ea.codebase.type_exists(&resolved_arg)
1108            {
1109                return true;
1110            }
1111
1112            // If param is a fully-qualified vendor class not in our codebase, we can't verify
1113            // the required type — suppress to avoid false positives on external library params.
1114            if param_fqcn.contains('\\')
1115                && !ea.codebase.type_exists(param_fqcn.as_ref())
1116                && !ea.codebase.type_exists(&resolved_param)
1117            {
1118                return true;
1119            }
1120
1121            false
1122        })
1123    })
1124}
1125
1126/// Strict codebase-aware subtype check for generic type parameter positions.
1127///
1128/// Unlike `named_object_subtype`, this does NOT include the coercion direction (param extends arg).
1129/// That relaxation exists for outer argument checking only — applying it inside type parameter
1130/// positions would incorrectly accept e.g. `Box<Animal>` → `Box<Cat>` in a covariant context
1131/// because `Cat extends Animal` would trigger the coercion acceptance.
1132fn strict_named_object_subtype(arg: &Union, param: &Union, ea: &ExpressionAnalyzer<'_>) -> bool {
1133    use mir_types::Atomic;
1134    arg.types.iter().all(|a_atomic| {
1135        let arg_fqcn: &Arc<str> = match a_atomic {
1136            Atomic::TNamedObject { fqcn, .. } => fqcn,
1137            Atomic::TNever => return true,
1138            _ => return false,
1139        };
1140        param.types.iter().any(|p_atomic| {
1141            let param_fqcn: &Arc<str> = match p_atomic {
1142                Atomic::TNamedObject { fqcn, .. } => fqcn,
1143                _ => return false,
1144            };
1145            let resolved_param = ea
1146                .codebase
1147                .resolve_class_name(&ea.file, param_fqcn.as_ref());
1148            let resolved_arg = ea.codebase.resolve_class_name(&ea.file, arg_fqcn.as_ref());
1149            // Forward direction only — arg must extend/implement param. No coercion.
1150            resolved_param == resolved_arg
1151                || arg_fqcn.as_ref() == resolved_param.as_str()
1152                || resolved_arg == param_fqcn.as_ref()
1153                || ea
1154                    .codebase
1155                    .extends_or_implements(arg_fqcn.as_ref(), &resolved_param)
1156                || ea
1157                    .codebase
1158                    .extends_or_implements(arg_fqcn.as_ref(), param_fqcn.as_ref())
1159                || ea
1160                    .codebase
1161                    .extends_or_implements(&resolved_arg, &resolved_param)
1162        })
1163    })
1164}
1165
1166/// Check whether generic type parameters are compatible according to each parameter's declared
1167/// variance (`@template-covariant`, `@template-contravariant`, or invariant by default).
1168///
1169/// - Covariant: `C<Sub>` satisfies `C<Super>` when `Sub <: Super`.
1170/// - Contravariant: `C<Super>` satisfies `C<Sub>` when `Super <: Sub` (reversed).
1171/// - Invariant: exact structural match required.
1172fn generic_type_params_compatible(
1173    arg_params: &[Union],
1174    param_params: &[Union],
1175    template_params: &[mir_codebase::storage::TemplateParam],
1176    ea: &ExpressionAnalyzer<'_>,
1177) -> bool {
1178    // Mismatched arity (raw / uninstantiated generic) — be permissive.
1179    if arg_params.len() != param_params.len() {
1180        return true;
1181    }
1182    // No type params on either side — trivially compatible.
1183    if arg_params.is_empty() {
1184        return true;
1185    }
1186
1187    for (i, (arg_p, param_p)) in arg_params.iter().zip(param_params.iter()).enumerate() {
1188        let variance = template_params
1189            .get(i)
1190            .map(|tp| tp.variance)
1191            .unwrap_or(mir_types::Variance::Invariant);
1192
1193        let compatible = match variance {
1194            mir_types::Variance::Covariant => {
1195                // C<Cat> satisfies C<Animal> when Cat <: Animal.
1196                arg_p.is_subtype_of_simple(param_p)
1197                    || param_p.is_mixed()
1198                    || arg_p.is_mixed()
1199                    || strict_named_object_subtype(arg_p, param_p, ea)
1200            }
1201            mir_types::Variance::Contravariant => {
1202                // C<Animal> satisfies C<Cat> when Animal <: Cat (reversed direction).
1203                param_p.is_subtype_of_simple(arg_p)
1204                    || arg_p.is_mixed()
1205                    || param_p.is_mixed()
1206                    || strict_named_object_subtype(param_p, arg_p, ea)
1207            }
1208            mir_types::Variance::Invariant => {
1209                // Exact structural match or mutual subtyping.
1210                arg_p == param_p
1211                    || arg_p.is_mixed()
1212                    || param_p.is_mixed()
1213                    || (arg_p.is_subtype_of_simple(param_p) && param_p.is_subtype_of_simple(arg_p))
1214            }
1215        };
1216
1217        if !compatible {
1218            return false;
1219        }
1220    }
1221
1222    true
1223}
1224
1225/// Returns true if the param type contains a template-like type (a TNamedObject whose FQCN
1226/// is a single uppercase letter or doesn't exist in the codebase) indicating the function
1227/// uses generics. We can't validate the argument type without full template instantiation.
1228fn param_contains_template_or_unknown(param_ty: &Union, ea: &ExpressionAnalyzer<'_>) -> bool {
1229    param_ty.types.iter().any(|atomic| match atomic {
1230        Atomic::TTemplateParam { .. } => true,
1231        Atomic::TNamedObject { fqcn, .. } => {
1232            !fqcn.contains('\\') && !ea.codebase.type_exists(fqcn.as_ref())
1233        }
1234        // class-string<T> where T is a template param (single-letter or unknown)
1235        Atomic::TClassString(Some(inner)) => {
1236            !inner.contains('\\') && !ea.codebase.type_exists(inner.as_ref())
1237        }
1238        Atomic::TArray { key: _, value }
1239        | Atomic::TList { value }
1240        | Atomic::TNonEmptyArray { key: _, value }
1241        | Atomic::TNonEmptyList { value } => value.types.iter().any(|v| match v {
1242            Atomic::TTemplateParam { .. } => true,
1243            Atomic::TNamedObject { fqcn, .. } => {
1244                !fqcn.contains('\\') && !ea.codebase.type_exists(fqcn.as_ref())
1245            }
1246            _ => false,
1247        }),
1248        _ => false,
1249    })
1250}
1251
1252/// Replace `TStaticObject` / `TSelf` in a method's return type with the actual receiver FQCN.
1253/// `static` (LSB) and `self` in trait context both resolve to the concrete receiver class.
1254fn substitute_static_in_return(ret: Union, receiver_fqcn: &Arc<str>) -> Union {
1255    use mir_types::Atomic;
1256    let from_docblock = ret.from_docblock;
1257    let types: Vec<Atomic> = ret
1258        .types
1259        .into_iter()
1260        .map(|a| match a {
1261            Atomic::TStaticObject { .. } | Atomic::TSelf { .. } => Atomic::TNamedObject {
1262                fqcn: receiver_fqcn.clone(),
1263                type_params: vec![],
1264            },
1265            other => other,
1266        })
1267        .collect();
1268    let mut result = Union::from_vec(types);
1269    result.from_docblock = from_docblock;
1270    result
1271}
1272
1273/// For a spread (`...`) argument, return the union of value types across all array atomics.
1274/// E.g. `array<int, int>` → `int`, `list<string>` → `string`, `mixed` → `mixed`.
1275/// This lets us compare the element type against the variadic param type.
1276pub fn spread_element_type(arr_ty: &Union) -> Union {
1277    use mir_types::Atomic;
1278    let mut result = Union::empty();
1279    for atomic in arr_ty.types.iter() {
1280        match atomic {
1281            Atomic::TArray { value, .. }
1282            | Atomic::TNonEmptyArray { value, .. }
1283            | Atomic::TList { value }
1284            | Atomic::TNonEmptyList { value } => {
1285                for t in value.types.iter() {
1286                    result.add_type(t.clone());
1287                }
1288            }
1289            Atomic::TKeyedArray { properties, .. } => {
1290                for (_key, prop) in properties.iter() {
1291                    for t in prop.ty.types.iter() {
1292                        result.add_type(t.clone());
1293                    }
1294                }
1295            }
1296            // If the spread value isn't an array (or is mixed), treat as mixed
1297            _ => return Union::mixed(),
1298        }
1299    }
1300    if result.types.is_empty() {
1301        Union::mixed()
1302    } else {
1303        result
1304    }
1305}
1306
1307/// Returns true if both arg and param are array/list types whose value types are compatible
1308/// with FQCN resolution (e.g., `array<int, FQCN>` satisfies `list<ShortName>`).
1309/// Recursive codebase-aware union compatibility check.
1310/// Returns true if every atomic in `arg_ty` is compatible with `param_ty`,
1311/// handling nested lists/arrays and FQCN resolution.
1312fn union_compatible(arg_ty: &Union, param_ty: &Union, ea: &ExpressionAnalyzer<'_>) -> bool {
1313    arg_ty.types.iter().all(|av| {
1314        // Named object: use FQCN resolution
1315        let av_fqcn: &Arc<str> = match av {
1316            Atomic::TNamedObject { fqcn, .. } => fqcn,
1317            Atomic::TSelf { fqcn } | Atomic::TStaticObject { fqcn } | Atomic::TParent { fqcn } => {
1318                fqcn
1319            }
1320            // Nested list/array: recurse
1321            Atomic::TArray { value, .. }
1322            | Atomic::TNonEmptyArray { value, .. }
1323            | Atomic::TList { value }
1324            | Atomic::TNonEmptyList { value } => {
1325                return param_ty.types.iter().any(|pv| {
1326                    let pv_val: &Union = match pv {
1327                        Atomic::TArray { value, .. }
1328                        | Atomic::TNonEmptyArray { value, .. }
1329                        | Atomic::TList { value }
1330                        | Atomic::TNonEmptyList { value } => value,
1331                        _ => return false,
1332                    };
1333                    union_compatible(value, pv_val, ea)
1334                });
1335            }
1336            Atomic::TKeyedArray { .. } => return true,
1337            _ => return Union::single(av.clone()).is_subtype_of_simple(param_ty),
1338        };
1339
1340        param_ty.types.iter().any(|pv| {
1341            let pv_fqcn: &Arc<str> = match pv {
1342                Atomic::TNamedObject { fqcn, .. } => fqcn,
1343                Atomic::TSelf { fqcn }
1344                | Atomic::TStaticObject { fqcn }
1345                | Atomic::TParent { fqcn } => fqcn,
1346                _ => return false,
1347            };
1348            // Template param wildcard
1349            if !pv_fqcn.contains('\\') && !ea.codebase.type_exists(pv_fqcn.as_ref()) {
1350                return true;
1351            }
1352            let resolved_param = ea.codebase.resolve_class_name(&ea.file, pv_fqcn.as_ref());
1353            let resolved_arg = ea.codebase.resolve_class_name(&ea.file, av_fqcn.as_ref());
1354            resolved_param == resolved_arg
1355                || ea
1356                    .codebase
1357                    .extends_or_implements(av_fqcn.as_ref(), &resolved_param)
1358                || ea
1359                    .codebase
1360                    .extends_or_implements(&resolved_arg, &resolved_param)
1361                || ea
1362                    .codebase
1363                    .extends_or_implements(pv_fqcn.as_ref(), &resolved_arg)
1364                || ea
1365                    .codebase
1366                    .extends_or_implements(&resolved_param, &resolved_arg)
1367        })
1368    })
1369}
1370
1371fn array_list_compatible(arg_ty: &Union, param_ty: &Union, ea: &ExpressionAnalyzer<'_>) -> bool {
1372    arg_ty.types.iter().all(|a_atomic| {
1373        let arg_value: &Union = match a_atomic {
1374            Atomic::TArray { value, .. }
1375            | Atomic::TNonEmptyArray { value, .. }
1376            | Atomic::TList { value }
1377            | Atomic::TNonEmptyList { value } => value,
1378            Atomic::TKeyedArray { .. } => return true, // keyed arrays are compatible with any list/array
1379            _ => return false,
1380        };
1381
1382        param_ty.types.iter().any(|p_atomic| {
1383            let param_value: &Union = match p_atomic {
1384                Atomic::TArray { value, .. }
1385                | Atomic::TNonEmptyArray { value, .. }
1386                | Atomic::TList { value }
1387                | Atomic::TNonEmptyList { value } => value,
1388                _ => return false,
1389            };
1390
1391            union_compatible(arg_value, param_value, ea)
1392        })
1393    })
1394}
1395
1396fn check_method_visibility(
1397    ea: &mut ExpressionAnalyzer<'_>,
1398    method: &MethodStorage,
1399    ctx: &Context,
1400    span: Span,
1401) {
1402    match method.visibility {
1403        Visibility::Private => {
1404            // Private methods can only be called from within the same class
1405            let caller_fqcn = ctx.self_fqcn.as_deref().unwrap_or("");
1406            if caller_fqcn != method.fqcn.as_ref() {
1407                ea.emit(
1408                    IssueKind::UndefinedMethod {
1409                        class: method.fqcn.to_string(),
1410                        method: method.name.to_string(),
1411                    },
1412                    Severity::Error,
1413                    span,
1414                );
1415            }
1416        }
1417        Visibility::Protected => {
1418            // Protected: callable only from within the declaring class or its subclasses
1419            let caller_fqcn = ctx.self_fqcn.as_deref().unwrap_or("");
1420            if caller_fqcn.is_empty() {
1421                // Called from outside any class — not allowed
1422                ea.emit(
1423                    IssueKind::UndefinedMethod {
1424                        class: method.fqcn.to_string(),
1425                        method: method.name.to_string(),
1426                    },
1427                    Severity::Error,
1428                    span,
1429                );
1430            } else {
1431                // Caller must be the method's class or a subclass of it
1432                let allowed = caller_fqcn == method.fqcn.as_ref()
1433                    || ea
1434                        .codebase
1435                        .extends_or_implements(caller_fqcn, method.fqcn.as_ref());
1436                if !allowed {
1437                    ea.emit(
1438                        IssueKind::UndefinedMethod {
1439                            class: method.fqcn.to_string(),
1440                            method: method.name.to_string(),
1441                        },
1442                        Severity::Error,
1443                        span,
1444                    );
1445                }
1446            }
1447        }
1448        Visibility::Public => {}
1449    }
1450}
1451
1452fn resolve_static_class(name: &str, ctx: &Context) -> String {
1453    match name.to_lowercase().as_str() {
1454        "self" => ctx.self_fqcn.as_deref().unwrap_or("self").to_string(),
1455        "parent" => ctx.parent_fqcn.as_deref().unwrap_or("parent").to_string(),
1456        "static" => ctx
1457            .static_fqcn
1458            .as_deref()
1459            .unwrap_or(ctx.self_fqcn.as_deref().unwrap_or("static"))
1460            .to_string(),
1461        _ => name.to_string(),
1462    }
1463}