Skip to main content

mir_analyzer/
call.rs

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