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