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