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