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