Skip to main content

mir_analyzer/
stmt.rs

1/// Statement analyzer — walks statement nodes threading context through
2/// control flow (if/else, loops, try/catch, return).
3use std::sync::Arc;
4
5use php_ast::ast::StmtKind;
6
7use mir_codebase::Codebase;
8use mir_issues::{Issue, IssueBuffer, IssueKind, Location};
9use mir_types::{ArrayKey, Atomic, Union};
10
11use crate::context::Context;
12use crate::expr::ExpressionAnalyzer;
13use crate::narrowing::narrow_from_condition;
14use crate::php_version::PhpVersion;
15use crate::symbol::ResolvedSymbol;
16
17// ---------------------------------------------------------------------------
18// StatementsAnalyzer
19// ---------------------------------------------------------------------------
20
21pub struct StatementsAnalyzer<'a> {
22    pub codebase: &'a Codebase,
23    pub file: Arc<str>,
24    pub source: &'a str,
25    pub source_map: &'a php_rs_parser::source_map::SourceMap,
26    pub issues: &'a mut IssueBuffer,
27    pub symbols: &'a mut Vec<ResolvedSymbol>,
28    pub php_version: PhpVersion,
29    /// Accumulated inferred return types for the current function.
30    pub return_types: Vec<Union>,
31    /// Break-context stack: one entry per active loop nesting level.
32    /// Each entry collects the context states at every `break` in that loop.
33    break_ctx_stack: Vec<Vec<Context>>,
34}
35
36impl<'a> StatementsAnalyzer<'a> {
37    pub fn new(
38        codebase: &'a Codebase,
39        file: Arc<str>,
40        source: &'a str,
41        source_map: &'a php_rs_parser::source_map::SourceMap,
42        issues: &'a mut IssueBuffer,
43        symbols: &'a mut Vec<ResolvedSymbol>,
44        php_version: PhpVersion,
45    ) -> Self {
46        Self {
47            codebase,
48            file,
49            source,
50            source_map,
51            issues,
52            symbols,
53            php_version,
54            return_types: Vec::new(),
55            break_ctx_stack: Vec::new(),
56        }
57    }
58
59    pub fn analyze_stmts<'arena, 'src>(
60        &mut self,
61        stmts: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Stmt<'arena, 'src>>,
62        ctx: &mut Context,
63    ) {
64        for stmt in stmts.iter() {
65            // @psalm-suppress / @suppress per-statement (call-site suppression)
66            let suppressions = self.extract_statement_suppressions(stmt.span);
67            let before = self.issues.issue_count();
68
69            if ctx.diverges {
70                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
71                let col_end = if stmt.span.start < stmt.span.end {
72                    let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
73                    end_col
74                } else {
75                    col_start + 1
76                };
77                self.issues.add(
78                    Issue::new(
79                        IssueKind::UnreachableCode,
80                        Location {
81                            file: self.file.clone(),
82                            line,
83                            col_start,
84                            col_end: col_end.max(col_start + 1),
85                        },
86                    )
87                    .with_snippet(
88                        crate::parser::span_text(self.source, stmt.span).unwrap_or_default(),
89                    ),
90                );
91                if !suppressions.is_empty() {
92                    self.issues.suppress_range(before, &suppressions);
93                }
94                break;
95            }
96
97            // Extract @var annotation for this statement.
98            let var_annotation = self.extract_var_annotation(stmt.span);
99
100            // Pre-narrow: `@var Type $varname` before any statement narrows that variable.
101            // Special cases: before `return` or before `foreach ... as $valvar` (value override).
102            if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
103                ctx.set_var(var_name.as_str(), var_ty.clone());
104            }
105
106            self.analyze_stmt(stmt, ctx);
107
108            // Post-narrow: `@var Type $varname` before `$varname = expr()` overrides
109            // the inferred type with the annotated type. Only applies when the assignment
110            // target IS the annotated variable.
111            if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
112                if let php_ast::ast::StmtKind::Expression(e) = &stmt.kind {
113                    if let php_ast::ast::ExprKind::Assign(a) = &e.kind {
114                        if matches!(&a.op, php_ast::ast::AssignOp::Assign) {
115                            if let php_ast::ast::ExprKind::Variable(lhs_name) = &a.target.kind {
116                                let lhs = lhs_name.trim_start_matches('$');
117                                if lhs == var_name.as_str() {
118                                    ctx.set_var(var_name.as_str(), var_ty.clone());
119                                }
120                            }
121                        }
122                    }
123                }
124            }
125
126            if !suppressions.is_empty() {
127                self.issues.suppress_range(before, &suppressions);
128            }
129        }
130    }
131
132    pub fn analyze_stmt<'arena, 'src>(
133        &mut self,
134        stmt: &php_ast::ast::Stmt<'arena, 'src>,
135        ctx: &mut Context,
136    ) {
137        match &stmt.kind {
138            // ---- Expression statement ----------------------------------------
139            StmtKind::Expression(expr) => {
140                let expr_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
141                if expr_ty.is_never() {
142                    ctx.diverges = true;
143                }
144                // For standalone assert($condition) calls, narrow from the condition.
145                if let php_ast::ast::ExprKind::FunctionCall(call) = &expr.kind {
146                    if let php_ast::ast::ExprKind::Identifier(fn_name) = &call.name.kind {
147                        if fn_name.eq_ignore_ascii_case("assert") {
148                            if let Some(arg) = call.args.first() {
149                                narrow_from_condition(
150                                    &arg.value,
151                                    ctx,
152                                    true,
153                                    self.codebase,
154                                    &self.file,
155                                );
156                            }
157                        }
158                    }
159                }
160            }
161
162            // ---- Echo ---------------------------------------------------------
163            StmtKind::Echo(exprs) => {
164                for expr in exprs.iter() {
165                    // Taint check (M19): echoing tainted data → XSS
166                    if crate::taint::is_expr_tainted(expr, ctx) {
167                        let (line, col_start) = self.offset_to_line_col(stmt.span.start);
168                        let col_end = if stmt.span.start < stmt.span.end {
169                            let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
170                            end_col
171                        } else {
172                            col_start
173                        };
174                        let mut issue = mir_issues::Issue::new(
175                            IssueKind::TaintedHtml,
176                            mir_issues::Location {
177                                file: self.file.clone(),
178                                line,
179                                col_start,
180                                col_end: col_end.max(col_start + 1),
181                            },
182                        );
183                        // Extract snippet from the echo statement span.
184                        let start = stmt.span.start as usize;
185                        let end = stmt.span.end as usize;
186                        if start < self.source.len() {
187                            let end = end.min(self.source.len());
188                            let span_text = &self.source[start..end];
189                            if let Some(first_line) = span_text.lines().next() {
190                                issue = issue.with_snippet(first_line.trim().to_string());
191                            }
192                        }
193                        self.issues.add(issue);
194                    }
195                    self.expr_analyzer(ctx).analyze(expr, ctx);
196                }
197            }
198
199            // ---- Return -------------------------------------------------------
200            StmtKind::Return(opt_expr) => {
201                if let Some(expr) = opt_expr {
202                    let ret_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
203
204                    // If there's a bare `@var Type` (no variable name) on the return statement,
205                    // use the annotated type for the return-type compatibility check.
206                    // `@var Type $name` with a variable name narrows the variable (handled in
207                    // analyze_stmts loop), not the return type.
208                    let check_ty =
209                        if let Some((None, var_ty)) = self.extract_var_annotation(stmt.span) {
210                            var_ty
211                        } else {
212                            ret_ty.clone()
213                        };
214
215                    // Check against declared return type
216                    if let Some(declared) = &ctx.fn_return_type.clone() {
217                        // Check return type compatibility. Special case: `void` functions must not
218                        // return any value (named_object_return_compatible considers TVoid compatible
219                        // with TNull, so handle void separately to avoid false suppression).
220                        if (declared.is_void() && !check_ty.is_void() && !check_ty.is_mixed())
221                            || (!check_ty.is_subtype_of_simple(declared)
222                                && !declared.is_mixed()
223                                && !check_ty.is_mixed()
224                                && !named_object_return_compatible(&check_ty, declared, self.codebase, &self.file)
225                                // Also check without null (handles `null|T` where T implements declared).
226                                // Guard: if check_ty is purely null, remove_null() is empty and would
227                                // vacuously return true, incorrectly suppressing the error.
228                                && (check_ty.remove_null().is_empty() || !named_object_return_compatible(&check_ty.remove_null(), declared, self.codebase, &self.file))
229                                && !declared_return_has_template(declared, self.codebase)
230                                && !declared_return_has_template(&check_ty, self.codebase)
231                                && !return_arrays_compatible(&check_ty, declared, self.codebase, &self.file)
232                                // Skip coercions: declared is more specific than actual
233                                && !declared.is_subtype_of_simple(&check_ty)
234                                && !declared.remove_null().is_subtype_of_simple(&check_ty)
235                                // Skip when actual is compatible after removing null/false.
236                                // Guard against empty union (e.g. pure-null type): removing null
237                                // from `null` alone gives an empty union which vacuously passes
238                                // is_subtype_of_simple — that would incorrectly suppress the error.
239                                && (check_ty.remove_null().is_empty() || !check_ty.remove_null().is_subtype_of_simple(declared))
240                                && !check_ty.remove_false().is_subtype_of_simple(declared)
241                                // Suppress LessSpecificReturnStatement (level 4): actual is a
242                                // supertype of declared (not flagged at default error level).
243                                && !named_object_return_compatible(declared, &check_ty, self.codebase, &self.file)
244                                && !named_object_return_compatible(&declared.remove_null(), &check_ty.remove_null(), self.codebase, &self.file))
245                        {
246                            let (line, col_start) = self.offset_to_line_col(stmt.span.start);
247                            let col_end = if stmt.span.start < stmt.span.end {
248                                let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
249                                end_col
250                            } else {
251                                col_start
252                            };
253                            self.issues.add(
254                                mir_issues::Issue::new(
255                                    IssueKind::InvalidReturnType {
256                                        expected: format!("{}", declared),
257                                        actual: format!("{}", ret_ty),
258                                    },
259                                    mir_issues::Location {
260                                        file: self.file.clone(),
261                                        line,
262                                        col_start,
263                                        col_end: col_end.max(col_start + 1),
264                                    },
265                                )
266                                .with_snippet(
267                                    crate::parser::span_text(self.source, stmt.span)
268                                        .unwrap_or_default(),
269                                ),
270                            );
271                        }
272                    }
273                    self.return_types.push(ret_ty);
274                } else {
275                    self.return_types.push(Union::single(Atomic::TVoid));
276                    // Bare `return;` from a non-void declared function is an error.
277                    if let Some(declared) = &ctx.fn_return_type.clone() {
278                        if !declared.is_void() && !declared.is_mixed() {
279                            let (line, col_start) = self.offset_to_line_col(stmt.span.start);
280                            let col_end = if stmt.span.start < stmt.span.end {
281                                let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
282                                end_col
283                            } else {
284                                col_start
285                            };
286                            self.issues.add(
287                                mir_issues::Issue::new(
288                                    IssueKind::InvalidReturnType {
289                                        expected: format!("{}", declared),
290                                        actual: "void".to_string(),
291                                    },
292                                    mir_issues::Location {
293                                        file: self.file.clone(),
294                                        line,
295                                        col_start,
296                                        col_end: col_end.max(col_start + 1),
297                                    },
298                                )
299                                .with_snippet(
300                                    crate::parser::span_text(self.source, stmt.span)
301                                        .unwrap_or_default(),
302                                ),
303                            );
304                        }
305                    }
306                }
307                ctx.diverges = true;
308            }
309
310            // ---- Throw --------------------------------------------------------
311            StmtKind::Throw(expr) => {
312                let thrown_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
313                // Validate that the thrown type extends Throwable
314                for atomic in &thrown_ty.types {
315                    match atomic {
316                        mir_types::Atomic::TNamedObject { fqcn, .. } => {
317                            let resolved = self.codebase.resolve_class_name(&self.file, fqcn);
318                            let is_throwable = resolved == "Throwable"
319                                || resolved == "Exception"
320                                || resolved == "Error"
321                                || fqcn.as_ref() == "Throwable"
322                                || fqcn.as_ref() == "Exception"
323                                || fqcn.as_ref() == "Error"
324                                || self.codebase.extends_or_implements(&resolved, "Throwable")
325                                || self.codebase.extends_or_implements(&resolved, "Exception")
326                                || self.codebase.extends_or_implements(&resolved, "Error")
327                                || self.codebase.extends_or_implements(fqcn, "Throwable")
328                                || self.codebase.extends_or_implements(fqcn, "Exception")
329                                || self.codebase.extends_or_implements(fqcn, "Error")
330                                // Suppress if class has unknown ancestors (might be Throwable)
331                                || self.codebase.has_unknown_ancestor(&resolved)
332                                || self.codebase.has_unknown_ancestor(fqcn)
333                                // Suppress if class is not in codebase at all (could be extension class)
334                                || (!self.codebase.type_exists(&resolved) && !self.codebase.type_exists(fqcn));
335                            if !is_throwable {
336                                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
337                                let col_end = if stmt.span.start < stmt.span.end {
338                                    let (_end_line, end_col) =
339                                        self.offset_to_line_col(stmt.span.end);
340                                    end_col
341                                } else {
342                                    col_start
343                                };
344                                self.issues.add(mir_issues::Issue::new(
345                                    IssueKind::InvalidThrow {
346                                        ty: fqcn.to_string(),
347                                    },
348                                    mir_issues::Location {
349                                        file: self.file.clone(),
350                                        line,
351                                        col_start,
352                                        col_end: col_end.max(col_start + 1),
353                                    },
354                                ));
355                            }
356                        }
357                        // self/static/parent resolve to the class itself — check via fqcn
358                        mir_types::Atomic::TSelf { fqcn }
359                        | mir_types::Atomic::TStaticObject { fqcn }
360                        | mir_types::Atomic::TParent { fqcn } => {
361                            let resolved = self.codebase.resolve_class_name(&self.file, fqcn);
362                            let is_throwable = resolved == "Throwable"
363                                || resolved == "Exception"
364                                || resolved == "Error"
365                                || self.codebase.extends_or_implements(&resolved, "Throwable")
366                                || self.codebase.extends_or_implements(&resolved, "Exception")
367                                || self.codebase.extends_or_implements(&resolved, "Error")
368                                || self.codebase.extends_or_implements(fqcn, "Throwable")
369                                || self.codebase.extends_or_implements(fqcn, "Exception")
370                                || self.codebase.extends_or_implements(fqcn, "Error")
371                                || self.codebase.has_unknown_ancestor(&resolved)
372                                || self.codebase.has_unknown_ancestor(fqcn);
373                            if !is_throwable {
374                                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
375                                let col_end = if stmt.span.start < stmt.span.end {
376                                    let (_end_line, end_col) =
377                                        self.offset_to_line_col(stmt.span.end);
378                                    end_col
379                                } else {
380                                    col_start
381                                };
382                                self.issues.add(mir_issues::Issue::new(
383                                    IssueKind::InvalidThrow {
384                                        ty: fqcn.to_string(),
385                                    },
386                                    mir_issues::Location {
387                                        file: self.file.clone(),
388                                        line,
389                                        col_start,
390                                        col_end: col_end.max(col_start + 1),
391                                    },
392                                ));
393                            }
394                        }
395                        mir_types::Atomic::TMixed | mir_types::Atomic::TObject => {}
396                        _ => {
397                            let (line, col_start) = self.offset_to_line_col(stmt.span.start);
398                            let col_end = if stmt.span.start < stmt.span.end {
399                                let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
400                                end_col
401                            } else {
402                                col_start
403                            };
404                            self.issues.add(mir_issues::Issue::new(
405                                IssueKind::InvalidThrow {
406                                    ty: format!("{}", thrown_ty),
407                                },
408                                mir_issues::Location {
409                                    file: self.file.clone(),
410                                    line,
411                                    col_start,
412                                    col_end: col_end.max(col_start + 1),
413                                },
414                            ));
415                        }
416                    }
417                }
418                ctx.diverges = true;
419            }
420
421            // ---- If -----------------------------------------------------------
422            StmtKind::If(if_stmt) => {
423                let pre_ctx = ctx.clone();
424
425                // Analyse condition expression
426                let cond_type = self.expr_analyzer(ctx).analyze(&if_stmt.condition, ctx);
427                let pre_diverges = ctx.diverges;
428
429                // True branch
430                let mut then_ctx = ctx.fork();
431                narrow_from_condition(
432                    &if_stmt.condition,
433                    &mut then_ctx,
434                    true,
435                    self.codebase,
436                    &self.file,
437                );
438                // Capture narrowing-only unreachability before body analysis —
439                // body divergence (continue/return/throw) must not trigger
440                // RedundantCondition for valid conditions.
441                let then_unreachable_from_narrowing = then_ctx.diverges;
442                // Skip analyzing a statically-unreachable branch (prevents false
443                // positives in dead branches caused by overly conservative types).
444                if !then_ctx.diverges {
445                    self.analyze_stmt(if_stmt.then_branch, &mut then_ctx);
446                }
447
448                // ElseIf branches (flatten into separate else-if chain)
449                let mut elseif_ctxs: Vec<Context> = vec![];
450                for elseif in if_stmt.elseif_branches.iter() {
451                    // Start from the pre-if context narrowed by the if condition being false
452                    // (an elseif body only runs when the if condition is false).
453                    let mut pre_elseif = ctx.fork();
454                    narrow_from_condition(
455                        &if_stmt.condition,
456                        &mut pre_elseif,
457                        false,
458                        self.codebase,
459                        &self.file,
460                    );
461                    let pre_elseif_diverges = pre_elseif.diverges;
462
463                    // Check reachability of the elseif body (condition narrowed true)
464                    // and its implicit "skip" path (condition narrowed false) to detect
465                    // redundant elseif conditions.
466                    let mut elseif_true_ctx = pre_elseif.clone();
467                    narrow_from_condition(
468                        &elseif.condition,
469                        &mut elseif_true_ctx,
470                        true,
471                        self.codebase,
472                        &self.file,
473                    );
474                    let mut elseif_false_ctx = pre_elseif.clone();
475                    narrow_from_condition(
476                        &elseif.condition,
477                        &mut elseif_false_ctx,
478                        false,
479                        self.codebase,
480                        &self.file,
481                    );
482                    if !pre_elseif_diverges
483                        && (elseif_true_ctx.diverges || elseif_false_ctx.diverges)
484                    {
485                        let (line, col_start) =
486                            self.offset_to_line_col(elseif.condition.span.start);
487                        let col_end = if elseif.condition.span.start < elseif.condition.span.end {
488                            let (_end_line, end_col) =
489                                self.offset_to_line_col(elseif.condition.span.end);
490                            end_col
491                        } else {
492                            col_start
493                        };
494                        let elseif_cond_type = self
495                            .expr_analyzer(ctx)
496                            .analyze(&elseif.condition, &mut ctx.fork());
497                        self.issues.add(
498                            mir_issues::Issue::new(
499                                IssueKind::RedundantCondition {
500                                    ty: format!("{}", elseif_cond_type),
501                                },
502                                mir_issues::Location {
503                                    file: self.file.clone(),
504                                    line,
505                                    col_start,
506                                    col_end: col_end.max(col_start + 1),
507                                },
508                            )
509                            .with_snippet(
510                                crate::parser::span_text(self.source, elseif.condition.span)
511                                    .unwrap_or_default(),
512                            ),
513                        );
514                    }
515
516                    // Analyze the elseif body using the narrowed-true context.
517                    let mut branch_ctx = elseif_true_ctx;
518                    self.expr_analyzer(&branch_ctx)
519                        .analyze(&elseif.condition, &mut branch_ctx);
520                    if !branch_ctx.diverges {
521                        self.analyze_stmt(&elseif.body, &mut branch_ctx);
522                    }
523                    elseif_ctxs.push(branch_ctx);
524                }
525
526                // Else branch
527                let mut else_ctx = ctx.fork();
528                narrow_from_condition(
529                    &if_stmt.condition,
530                    &mut else_ctx,
531                    false,
532                    self.codebase,
533                    &self.file,
534                );
535                let else_unreachable_from_narrowing = else_ctx.diverges;
536                if !else_ctx.diverges {
537                    if let Some(else_branch) = &if_stmt.else_branch {
538                        self.analyze_stmt(else_branch, &mut else_ctx);
539                    }
540                }
541
542                // Emit RedundantCondition if narrowing proves one branch is statically unreachable.
543                if !pre_diverges
544                    && (then_unreachable_from_narrowing || else_unreachable_from_narrowing)
545                {
546                    let (line, col_start) = self.offset_to_line_col(if_stmt.condition.span.start);
547                    let col_end = if if_stmt.condition.span.start < if_stmt.condition.span.end {
548                        let (_end_line, end_col) =
549                            self.offset_to_line_col(if_stmt.condition.span.end);
550                        end_col
551                    } else {
552                        col_start
553                    };
554                    self.issues.add(
555                        mir_issues::Issue::new(
556                            IssueKind::RedundantCondition {
557                                ty: format!("{}", cond_type),
558                            },
559                            mir_issues::Location {
560                                file: self.file.clone(),
561                                line,
562                                col_start,
563                                col_end: col_end.max(col_start + 1),
564                            },
565                        )
566                        .with_snippet(
567                            crate::parser::span_text(self.source, if_stmt.condition.span)
568                                .unwrap_or_default(),
569                        ),
570                    );
571                }
572
573                // Merge all branches: start with the if/else pair, then fold each
574                // elseif in as an additional possible execution path.  Using the
575                // accumulated ctx (not pre_ctx) as the "else" argument ensures every
576                // branch contributes to the final type environment.
577                *ctx = Context::merge_branches(&pre_ctx, then_ctx, Some(else_ctx));
578                for ec in elseif_ctxs {
579                    *ctx = Context::merge_branches(&pre_ctx, ec, Some(ctx.clone()));
580                }
581            }
582
583            // ---- While --------------------------------------------------------
584            StmtKind::While(w) => {
585                self.expr_analyzer(ctx).analyze(&w.condition, ctx);
586                let pre = ctx.clone();
587
588                // Entry context: narrow on true condition
589                let mut entry = ctx.fork();
590                narrow_from_condition(&w.condition, &mut entry, true, self.codebase, &self.file);
591
592                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
593                    sa.analyze_stmt(w.body, iter);
594                    sa.expr_analyzer(iter).analyze(&w.condition, iter);
595                });
596                *ctx = post;
597            }
598
599            // ---- Do-while -----------------------------------------------------
600            StmtKind::DoWhile(dw) => {
601                let pre = ctx.clone();
602                let entry = ctx.fork();
603                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
604                    sa.analyze_stmt(dw.body, iter);
605                    sa.expr_analyzer(iter).analyze(&dw.condition, iter);
606                });
607                *ctx = post;
608            }
609
610            // ---- For ----------------------------------------------------------
611            StmtKind::For(f) => {
612                // Init expressions run once before the loop
613                for init in f.init.iter() {
614                    self.expr_analyzer(ctx).analyze(init, ctx);
615                }
616                let pre = ctx.clone();
617                let mut entry = ctx.fork();
618                for cond in f.condition.iter() {
619                    self.expr_analyzer(&entry).analyze(cond, &mut entry);
620                }
621
622                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
623                    sa.analyze_stmt(f.body, iter);
624                    for update in f.update.iter() {
625                        sa.expr_analyzer(iter).analyze(update, iter);
626                    }
627                    for cond in f.condition.iter() {
628                        sa.expr_analyzer(iter).analyze(cond, iter);
629                    }
630                });
631                *ctx = post;
632            }
633
634            // ---- Foreach ------------------------------------------------------
635            StmtKind::Foreach(fe) => {
636                let arr_ty = self.expr_analyzer(ctx).analyze(&fe.expr, ctx);
637                let (key_ty, mut value_ty) = infer_foreach_types(&arr_ty);
638
639                // Apply `@var Type $varname` annotation on the foreach value variable.
640                // The annotation always wins — it is the developer's explicit type assertion.
641                if let Some(vname) = crate::expr::extract_simple_var(&fe.value) {
642                    if let Some((Some(ann_var), ann_ty)) = self.extract_var_annotation(stmt.span) {
643                        if ann_var == vname {
644                            value_ty = ann_ty;
645                        }
646                    }
647                }
648
649                let pre = ctx.clone();
650                let mut entry = ctx.fork();
651
652                // Bind key variable on loop entry
653                if let Some(key_expr) = &fe.key {
654                    if let Some(var_name) = crate::expr::extract_simple_var(key_expr) {
655                        entry.set_var(var_name, key_ty.clone());
656                    }
657                }
658                // Bind value variable on loop entry.
659                // The value may be a simple variable or a list/array destructure pattern.
660                let value_var = crate::expr::extract_simple_var(&fe.value);
661                let value_destructure_vars = crate::expr::extract_destructure_vars(&fe.value);
662                if let Some(ref vname) = value_var {
663                    entry.set_var(vname.as_str(), value_ty.clone());
664                } else {
665                    for vname in &value_destructure_vars {
666                        entry.set_var(vname, Union::mixed());
667                    }
668                }
669
670                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
671                    // Re-bind key/value each iteration (array may change)
672                    if let Some(key_expr) = &fe.key {
673                        if let Some(var_name) = crate::expr::extract_simple_var(key_expr) {
674                            iter.set_var(var_name, key_ty.clone());
675                        }
676                    }
677                    if let Some(ref vname) = value_var {
678                        iter.set_var(vname.as_str(), value_ty.clone());
679                    } else {
680                        for vname in &value_destructure_vars {
681                            iter.set_var(vname, Union::mixed());
682                        }
683                    }
684                    sa.analyze_stmt(fe.body, iter);
685                });
686                *ctx = post;
687            }
688
689            // ---- Switch -------------------------------------------------------
690            StmtKind::Switch(sw) => {
691                let _subject_ty = self.expr_analyzer(ctx).analyze(&sw.expr, ctx);
692                // Extract the subject variable name for narrowing (if it's a simple var)
693                let subject_var: Option<String> = match &sw.expr.kind {
694                    php_ast::ast::ExprKind::Variable(name) => {
695                        Some(name.as_str().trim_start_matches('$').to_string())
696                    }
697                    _ => None,
698                };
699                // Detect `switch(true)` — case conditions are used as narrowing expressions
700                let switch_on_true = matches!(&sw.expr.kind, php_ast::ast::ExprKind::Bool(true));
701
702                let pre_ctx = ctx.clone();
703                // Push a break-context bucket so that `break` inside cases saves
704                // the case's context for merging into the post-switch result.
705                self.break_ctx_stack.push(Vec::new());
706
707                let has_default = sw.cases.iter().any(|c| c.value.is_none());
708
709                // First pass: analyse each case body independently from pre_ctx.
710                // Break statements inside a body save their context to break_ctx_stack
711                // automatically; we just collect the per-case contexts here.
712                let mut case_results: Vec<Context> = Vec::new();
713                for case in sw.cases.iter() {
714                    let mut case_ctx = pre_ctx.fork();
715                    if let Some(val) = &case.value {
716                        if switch_on_true {
717                            // `switch(true) { case $x instanceof Y: }` — narrow from condition
718                            narrow_from_condition(
719                                val,
720                                &mut case_ctx,
721                                true,
722                                self.codebase,
723                                &self.file,
724                            );
725                        } else if let Some(ref var_name) = subject_var {
726                            // Narrow subject var to the literal type of the case value
727                            let narrow_ty = match &val.kind {
728                                php_ast::ast::ExprKind::Int(n) => {
729                                    Some(Union::single(Atomic::TLiteralInt(*n)))
730                                }
731                                php_ast::ast::ExprKind::String(s) => {
732                                    Some(Union::single(Atomic::TLiteralString(Arc::from(&**s))))
733                                }
734                                php_ast::ast::ExprKind::Bool(b) => Some(Union::single(if *b {
735                                    Atomic::TTrue
736                                } else {
737                                    Atomic::TFalse
738                                })),
739                                php_ast::ast::ExprKind::Null => Some(Union::single(Atomic::TNull)),
740                                _ => None,
741                            };
742                            if let Some(narrowed) = narrow_ty {
743                                case_ctx.set_var(var_name, narrowed);
744                            }
745                        }
746                        self.expr_analyzer(&case_ctx).analyze(val, &mut case_ctx);
747                    }
748                    self.analyze_stmts(&case.body, &mut case_ctx);
749                    case_results.push(case_ctx);
750                }
751
752                // Second pass: propagate divergence backwards through the fallthrough
753                // chain. A non-diverging case (no break/return/throw) flows into the
754                // next case at runtime, so if that next case effectively diverges, this
755                // case effectively diverges too.
756                //
757                // Example:
758                //   case 1: $y = "a";   // no break — chains into case 2
759                //   case 2: return;     // diverges
760                //
761                // Case 1 is effectively diverging because its only exit is through
762                // case 2's return. Adding case 1 to fallthrough_ctxs would be wrong.
763                let n = case_results.len();
764                let mut effective_diverges = vec![false; n];
765                for i in (0..n).rev() {
766                    if case_results[i].diverges {
767                        effective_diverges[i] = true;
768                    } else if i + 1 < n {
769                        // Non-diverging body: falls through to the next case.
770                        effective_diverges[i] = effective_diverges[i + 1];
771                    }
772                    // else: last case with no break/return — falls to end of switch.
773                }
774
775                // Build fallthrough_ctxs from cases that truly exit via the end of
776                // the switch (not through a subsequent diverging case).
777                let mut all_cases_diverge = true;
778                let mut fallthrough_ctxs: Vec<Context> = Vec::new();
779                for (i, case_ctx) in case_results.into_iter().enumerate() {
780                    if !effective_diverges[i] {
781                        all_cases_diverge = false;
782                        fallthrough_ctxs.push(case_ctx);
783                    }
784                }
785
786                // Pop break contexts — each `break` in a case body pushed its
787                // context here, representing that case's effect on post-switch state.
788                let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
789
790                // Build the post-switch merged context:
791                // Start with pre_ctx if no default case (switch might not match anything)
792                // or if not all cases diverge via return/throw.
793                let mut merged = if has_default
794                    && all_cases_diverge
795                    && break_ctxs.is_empty()
796                    && fallthrough_ctxs.is_empty()
797                {
798                    // All paths return/throw — post-switch is unreachable
799                    let mut m = pre_ctx.clone();
800                    m.diverges = true;
801                    m
802                } else {
803                    // Start from pre_ctx (covers the "no case matched" path when there
804                    // is no default, plus ensures pre-existing variables are preserved).
805                    pre_ctx.clone()
806                };
807
808                for bctx in break_ctxs {
809                    merged = Context::merge_branches(&pre_ctx, bctx, Some(merged));
810                }
811                for fctx in fallthrough_ctxs {
812                    merged = Context::merge_branches(&pre_ctx, fctx, Some(merged));
813                }
814
815                *ctx = merged;
816            }
817
818            // ---- Try/catch/finally -------------------------------------------
819            StmtKind::TryCatch(tc) => {
820                let pre_ctx = ctx.clone();
821                let mut try_ctx = ctx.fork();
822                self.analyze_stmts(&tc.body, &mut try_ctx);
823
824                // Build a base context for catch blocks that merges pre and try contexts.
825                // Variables that might have been set during the try body are "possibly assigned"
826                // in the catch (they may or may not have been set before the exception fired).
827                let catch_base = Context::merge_branches(&pre_ctx, try_ctx.clone(), None);
828
829                let mut non_diverging_catches: Vec<Context> = vec![];
830                for catch in tc.catches.iter() {
831                    let mut catch_ctx = catch_base.clone();
832                    // Check that all caught exception types exist.
833                    for catch_ty in catch.types.iter() {
834                        self.check_name_undefined_class(catch_ty);
835                    }
836                    if let Some(var) = catch.var {
837                        // Bind the caught exception variable; union all caught types
838                        let exc_ty = if catch.types.is_empty() {
839                            Union::single(Atomic::TObject)
840                        } else {
841                            let mut u = Union::empty();
842                            for catch_ty in catch.types.iter() {
843                                let raw = crate::parser::name_to_string(catch_ty);
844                                let resolved = self.codebase.resolve_class_name(&self.file, &raw);
845                                u.add_type(Atomic::TNamedObject {
846                                    fqcn: resolved.into(),
847                                    type_params: vec![],
848                                });
849                            }
850                            u
851                        };
852                        catch_ctx.set_var(var.trim_start_matches('$'), exc_ty);
853                    }
854                    self.analyze_stmts(&catch.body, &mut catch_ctx);
855                    if !catch_ctx.diverges {
856                        non_diverging_catches.push(catch_ctx);
857                    }
858                }
859
860                // If ALL catch branches diverge (return/throw/continue/break),
861                // code after the try/catch is only reachable from the try body.
862                // Use try_ctx directly so variables assigned in try are definitely set.
863                let mut result = if non_diverging_catches.is_empty() {
864                    let mut r = try_ctx;
865                    r.diverges = false; // the try body itself may not have diverged
866                    r
867                } else {
868                    // Some catches don't diverge — merge try with all non-diverging catches.
869                    // Chain the merges: start with try_ctx, then fold in each catch branch.
870                    let mut r = try_ctx;
871                    for catch_ctx in non_diverging_catches {
872                        r = Context::merge_branches(&pre_ctx, r, Some(catch_ctx));
873                    }
874                    r
875                };
876
877                // Finally runs unconditionally — analyze but don't merge vars
878                if let Some(finally_stmts) = &tc.finally {
879                    let mut finally_ctx = result.clone();
880                    finally_ctx.inside_finally = true;
881                    self.analyze_stmts(finally_stmts, &mut finally_ctx);
882                    if finally_ctx.diverges {
883                        result.diverges = true;
884                    }
885                }
886
887                *ctx = result;
888            }
889
890            // ---- Block --------------------------------------------------------
891            StmtKind::Block(stmts) => {
892                self.analyze_stmts(stmts, ctx);
893            }
894
895            // ---- Break --------------------------------------------------------
896            StmtKind::Break(_) => {
897                // Save the context at the break point so the post-loop context
898                // accounts for this early-exit path.
899                if let Some(break_ctxs) = self.break_ctx_stack.last_mut() {
900                    break_ctxs.push(ctx.clone());
901                }
902                // Context after an unconditional break is dead; don't continue
903                // emitting issues for code after this point.
904                ctx.diverges = true;
905            }
906
907            // ---- Continue ----------------------------------------------------
908            StmtKind::Continue(_) => {
909                // continue goes back to the loop condition — no context to save,
910                // the widening pass already re-analyses the body.
911                ctx.diverges = true;
912            }
913
914            // ---- Unset --------------------------------------------------------
915            StmtKind::Unset(vars) => {
916                for var in vars.iter() {
917                    if let php_ast::ast::ExprKind::Variable(name) = &var.kind {
918                        ctx.unset_var(name.as_str().trim_start_matches('$'));
919                    }
920                }
921            }
922
923            // ---- Static variable declaration ---------------------------------
924            StmtKind::StaticVar(vars) => {
925                for sv in vars.iter() {
926                    let ty = Union::mixed(); // static vars are indeterminate on entry
927                    ctx.set_var(sv.name.trim_start_matches('$'), ty);
928                }
929            }
930
931            // ---- Global declaration ------------------------------------------
932            StmtKind::Global(vars) => {
933                for var in vars.iter() {
934                    if let php_ast::ast::ExprKind::Variable(name) = &var.kind {
935                        let var_name = name.as_str().trim_start_matches('$');
936                        let ty = self
937                            .codebase
938                            .global_vars
939                            .get(var_name)
940                            .map(|r| r.clone())
941                            .unwrap_or_else(Union::mixed);
942                        ctx.set_var(var_name, ty);
943                    }
944                }
945            }
946
947            // ---- Declare -----------------------------------------------------
948            StmtKind::Declare(d) => {
949                for (name, _val) in d.directives.iter() {
950                    if *name == "strict_types" {
951                        ctx.strict_types = true;
952                    }
953                }
954                if let Some(body) = &d.body {
955                    self.analyze_stmt(body, ctx);
956                }
957            }
958
959            // ---- Nested declarations (inside function bodies) ----------------
960            StmtKind::Function(decl) => {
961                // Nested named function — analyze its body in the same issue buffer
962                // so that undefined-function/class calls inside it are reported.
963                let params: Vec<mir_codebase::FnParam> = decl
964                    .params
965                    .iter()
966                    .map(|p| mir_codebase::FnParam {
967                        name: std::sync::Arc::from(p.name.trim_start_matches('$')),
968                        ty: None,
969                        default: p.default.as_ref().map(|_| Union::mixed()),
970                        is_variadic: p.variadic,
971                        is_byref: p.by_ref,
972                        is_optional: p.default.is_some() || p.variadic,
973                    })
974                    .collect();
975                let mut fn_ctx =
976                    Context::for_function(&params, None, None, None, None, ctx.strict_types, true);
977                let mut sa = StatementsAnalyzer::new(
978                    self.codebase,
979                    self.file.clone(),
980                    self.source,
981                    self.source_map,
982                    self.issues,
983                    self.symbols,
984                    self.php_version,
985                );
986                sa.analyze_stmts(&decl.body, &mut fn_ctx);
987            }
988
989            StmtKind::Class(decl) => {
990                // Nested class declaration — analyze each method body in the same
991                // issue buffer so that undefined-function/class calls are reported.
992                let class_name = decl.name.unwrap_or("<anonymous>");
993                let resolved = self.codebase.resolve_class_name(&self.file, class_name);
994                let fqcn: Arc<str> = Arc::from(resolved.as_str());
995                let parent_fqcn = self
996                    .codebase
997                    .classes
998                    .get(fqcn.as_ref())
999                    .and_then(|c| c.parent.clone());
1000
1001                for member in decl.members.iter() {
1002                    let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
1003                        continue;
1004                    };
1005                    let Some(body) = &method.body else { continue };
1006                    let (params, return_ty) = self
1007                        .codebase
1008                        .get_method(fqcn.as_ref(), method.name)
1009                        .as_deref()
1010                        .map(|m| (m.params.clone(), m.return_type.clone()))
1011                        .unwrap_or_else(|| {
1012                            let ast_params = method
1013                                .params
1014                                .iter()
1015                                .map(|p| mir_codebase::FnParam {
1016                                    name: p.name.trim_start_matches('$').into(),
1017                                    ty: None,
1018                                    default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
1019                                    is_variadic: p.variadic,
1020                                    is_byref: p.by_ref,
1021                                    is_optional: p.default.is_some() || p.variadic,
1022                                })
1023                                .collect();
1024                            (ast_params, None)
1025                        });
1026                    let is_ctor = method.name == "__construct";
1027                    let mut method_ctx = Context::for_method(
1028                        &params,
1029                        return_ty,
1030                        Some(fqcn.clone()),
1031                        parent_fqcn.clone(),
1032                        Some(fqcn.clone()),
1033                        ctx.strict_types,
1034                        is_ctor,
1035                        method.is_static,
1036                    );
1037                    let mut sa = StatementsAnalyzer::new(
1038                        self.codebase,
1039                        self.file.clone(),
1040                        self.source,
1041                        self.source_map,
1042                        self.issues,
1043                        self.symbols,
1044                        self.php_version,
1045                    );
1046                    sa.analyze_stmts(body, &mut method_ctx);
1047                }
1048            }
1049
1050            StmtKind::Interface(_) | StmtKind::Trait(_) | StmtKind::Enum(_) => {
1051                // Interfaces/traits/enums are collected in Pass 1 — skip here
1052            }
1053
1054            // ---- Namespace / use (at file level, already handled in Pass 1) --
1055            StmtKind::Namespace(_) | StmtKind::Use(_) | StmtKind::Const(_) => {}
1056
1057            // ---- Inert --------------------------------------------------------
1058            StmtKind::InlineHtml(_)
1059            | StmtKind::Nop
1060            | StmtKind::Goto(_)
1061            | StmtKind::Label(_)
1062            | StmtKind::HaltCompiler(_) => {}
1063
1064            StmtKind::Error => {}
1065        }
1066    }
1067
1068    // -----------------------------------------------------------------------
1069    // Helper: create a short-lived ExpressionAnalyzer borrowing our fields
1070    // -----------------------------------------------------------------------
1071
1072    fn expr_analyzer<'b>(&'b mut self, _ctx: &Context) -> ExpressionAnalyzer<'b>
1073    where
1074        'a: 'b,
1075    {
1076        ExpressionAnalyzer::new(
1077            self.codebase,
1078            self.file.clone(),
1079            self.source,
1080            self.source_map,
1081            self.issues,
1082            self.symbols,
1083            self.php_version,
1084        )
1085    }
1086
1087    /// Convert a byte offset to a Unicode char-count column on a given line.
1088    /// Returns (line, col) where col is a 0-based Unicode code-point count.
1089    fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1090        let lc = self.source_map.offset_to_line_col(offset);
1091        let line = lc.line + 1;
1092
1093        let byte_offset = offset as usize;
1094        let line_start_byte = if byte_offset == 0 {
1095            0
1096        } else {
1097            self.source[..byte_offset]
1098                .rfind('\n')
1099                .map(|p| p + 1)
1100                .unwrap_or(0)
1101        };
1102
1103        let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1104
1105        (line, col)
1106    }
1107
1108    /// Emit `UndefinedClass` for a `Name` AST node if the resolved class does not exist.
1109    fn check_name_undefined_class(&mut self, name: &php_ast::ast::Name<'_, '_>) {
1110        let raw = crate::parser::name_to_string(name);
1111        let resolved = self.codebase.resolve_class_name(&self.file, &raw);
1112        if matches!(resolved.as_str(), "self" | "static" | "parent") {
1113            return;
1114        }
1115        if self.codebase.type_exists(&resolved) {
1116            return;
1117        }
1118        let span = name.span();
1119        let (line, col_start) = self.offset_to_line_col(span.start);
1120        let (_, col_end) = self.offset_to_line_col(span.end);
1121        self.issues.add(Issue::new(
1122            IssueKind::UndefinedClass { name: resolved },
1123            Location {
1124                file: self.file.clone(),
1125                line,
1126                col_start,
1127                col_end: col_end.max(col_start + 1),
1128            },
1129        ));
1130    }
1131
1132    // -----------------------------------------------------------------------
1133    // @psalm-suppress / @suppress per-statement
1134    // -----------------------------------------------------------------------
1135
1136    /// Extract suppression names from the `@psalm-suppress` / `@suppress`
1137    /// annotation in the docblock immediately preceding `span`.
1138    fn extract_statement_suppressions(&self, span: php_ast::Span) -> Vec<String> {
1139        let Some(doc) = crate::parser::find_preceding_docblock(self.source, span.start) else {
1140            return vec![];
1141        };
1142        let mut suppressions = Vec::new();
1143        for line in doc.lines() {
1144            let line = line.trim().trim_start_matches('*').trim();
1145            let rest = if let Some(r) = line.strip_prefix("@psalm-suppress ") {
1146                r
1147            } else if let Some(r) = line.strip_prefix("@suppress ") {
1148                r
1149            } else {
1150                continue;
1151            };
1152            for name in rest.split_whitespace() {
1153                suppressions.push(name.to_string());
1154            }
1155        }
1156        suppressions
1157    }
1158
1159    /// Extract `@var Type [$varname]` from the docblock immediately preceding `span`.
1160    /// Returns `(optional_var_name, resolved_type)` if an annotation exists.
1161    /// The type is resolved through the codebase's file-level imports/namespace.
1162    fn extract_var_annotation(
1163        &self,
1164        span: php_ast::Span,
1165    ) -> Option<(Option<String>, mir_types::Union)> {
1166        let doc = crate::parser::find_preceding_docblock(self.source, span.start)?;
1167        let parsed = crate::parser::DocblockParser::parse(&doc);
1168        let ty = parsed.var_type?;
1169        let resolved = resolve_union_for_file(ty, self.codebase, &self.file);
1170        Some((parsed.var_name, resolved))
1171    }
1172
1173    // -----------------------------------------------------------------------
1174    // Fixed-point loop widening (M12)
1175    // -----------------------------------------------------------------------
1176
1177    /// Analyse a loop body with a fixed-point widening algorithm (≤ 3 passes).
1178    ///
1179    /// * `pre`   — context *before* the loop (used as the merge base)
1180    /// * `entry` — context on first iteration entry (may be narrowed / seeded)
1181    /// * `body`  — closure that analyses one loop iteration, receives `&mut Self`
1182    ///   and `&mut Context` for the current iteration context
1183    ///
1184    /// Returns the post-loop context that merges:
1185    ///   - the stable widened context after normal loop exit
1186    ///   - any contexts captured at `break` statements
1187    fn analyze_loop_widened<F>(&mut self, pre: &Context, entry: Context, mut body: F) -> Context
1188    where
1189        F: FnMut(&mut Self, &mut Context),
1190    {
1191        const MAX_ITERS: usize = 3;
1192
1193        // Push a fresh break-context bucket for this loop level
1194        self.break_ctx_stack.push(Vec::new());
1195
1196        let mut current = entry;
1197        current.inside_loop = true;
1198
1199        for _ in 0..MAX_ITERS {
1200            let prev_vars = current.vars.clone();
1201
1202            let mut iter = current.clone();
1203            body(self, &mut iter);
1204
1205            let next = Context::merge_branches(pre, iter, None);
1206
1207            if vars_stabilized(&prev_vars, &next.vars) {
1208                current = next;
1209                break;
1210            }
1211            current = next;
1212        }
1213
1214        // Widen any variable still unstable after MAX_ITERS to `mixed`
1215        widen_unstable(&pre.vars, &mut current.vars);
1216
1217        // Pop break contexts and merge them into the post-loop result
1218        let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
1219        for bctx in break_ctxs {
1220            current = Context::merge_branches(pre, current, Some(bctx));
1221        }
1222
1223        current
1224    }
1225}
1226
1227// ---------------------------------------------------------------------------
1228// Loop widening helpers
1229// ---------------------------------------------------------------------------
1230
1231/// Returns true when every variable present in `prev` has the same type in
1232/// `next`, indicating the fixed-point has been reached.
1233fn vars_stabilized(
1234    prev: &indexmap::IndexMap<String, Union>,
1235    next: &indexmap::IndexMap<String, Union>,
1236) -> bool {
1237    if prev.len() != next.len() {
1238        return false;
1239    }
1240    prev.iter()
1241        .all(|(k, v)| next.get(k).map(|u| u == v).unwrap_or(false))
1242}
1243
1244/// For any variable whose type changed relative to `pre_vars`, widen to
1245/// `mixed`.  Called after MAX_ITERS to avoid non-termination.
1246fn widen_unstable(
1247    pre_vars: &indexmap::IndexMap<String, Union>,
1248    current_vars: &mut indexmap::IndexMap<String, Union>,
1249) {
1250    for (name, ty) in current_vars.iter_mut() {
1251        if pre_vars.get(name).map(|p| p != ty).unwrap_or(true) && !ty.is_mixed() {
1252            *ty = Union::mixed();
1253        }
1254    }
1255}
1256
1257// ---------------------------------------------------------------------------
1258// foreach key/value type inference
1259// ---------------------------------------------------------------------------
1260
1261fn infer_foreach_types(arr_ty: &Union) -> (Union, Union) {
1262    if arr_ty.is_mixed() {
1263        return (Union::mixed(), Union::mixed());
1264    }
1265    for atomic in &arr_ty.types {
1266        match atomic {
1267            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
1268                return (*key.clone(), *value.clone());
1269            }
1270            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1271                return (Union::single(Atomic::TInt), *value.clone());
1272            }
1273            Atomic::TKeyedArray { properties, .. } => {
1274                let mut keys = Union::empty();
1275                let mut values = Union::empty();
1276                for (k, prop) in properties {
1277                    let key_atomic = match k {
1278                        ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1279                        ArrayKey::Int(i) => Atomic::TLiteralInt(*i),
1280                    };
1281                    keys = Union::merge(&keys, &Union::single(key_atomic));
1282                    values = Union::merge(&values, &prop.ty);
1283                }
1284                // Empty keyed array (e.g. `$arr = []` before push) — treat both as
1285                // mixed to avoid propagating Union::empty() as a variable type.
1286                let keys = if keys.is_empty() {
1287                    Union::mixed()
1288                } else {
1289                    keys
1290                };
1291                let values = if values.is_empty() {
1292                    Union::mixed()
1293                } else {
1294                    values
1295                };
1296                return (keys, values);
1297            }
1298            Atomic::TString => {
1299                return (Union::single(Atomic::TInt), Union::single(Atomic::TString));
1300            }
1301            _ => {}
1302        }
1303    }
1304    (Union::mixed(), Union::mixed())
1305}
1306
1307// ---------------------------------------------------------------------------
1308// Named-object return type compatibility check
1309// ---------------------------------------------------------------------------
1310
1311/// Returns true if `actual` is compatible with `declared` considering class
1312/// hierarchy, self/static resolution, and short-name vs FQCN mismatches.
1313fn named_object_return_compatible(
1314    actual: &Union,
1315    declared: &Union,
1316    codebase: &Codebase,
1317    file: &str,
1318) -> bool {
1319    actual.types.iter().all(|actual_atom| {
1320        // Extract the actual FQCN — handles TNamedObject, TSelf, TStaticObject, TParent
1321        let actual_fqcn: &Arc<str> = match actual_atom {
1322            Atomic::TNamedObject { fqcn, .. } => fqcn,
1323            Atomic::TSelf { fqcn } => fqcn,
1324            Atomic::TStaticObject { fqcn } => fqcn,
1325            Atomic::TParent { fqcn } => fqcn,
1326            // TNull: compatible if declared also includes null
1327            Atomic::TNull => return declared.types.iter().any(|d| matches!(d, Atomic::TNull)),
1328            // TVoid: compatible with void declared
1329            Atomic::TVoid => {
1330                return declared
1331                    .types
1332                    .iter()
1333                    .any(|d| matches!(d, Atomic::TVoid | Atomic::TNull))
1334            }
1335            // TNever is the bottom type — compatible with anything
1336            Atomic::TNever => return true,
1337            // class-string<X> is compatible with class-string<Y> if X extends/implements Y
1338            Atomic::TClassString(Some(actual_cls)) => {
1339                return declared.types.iter().any(|d| match d {
1340                    Atomic::TClassString(None) => true,
1341                    Atomic::TClassString(Some(declared_cls)) => {
1342                        actual_cls == declared_cls
1343                            || codebase
1344                                .extends_or_implements(actual_cls.as_ref(), declared_cls.as_ref())
1345                    }
1346                    Atomic::TString => true,
1347                    _ => false,
1348                });
1349            }
1350            Atomic::TClassString(None) => {
1351                return declared
1352                    .types
1353                    .iter()
1354                    .any(|d| matches!(d, Atomic::TClassString(_) | Atomic::TString));
1355            }
1356            // Non-object types: not handled here (fall through to simple subtype check)
1357            _ => return false,
1358        };
1359
1360        declared.types.iter().any(|declared_atom| {
1361            // Extract declared FQCN — also handle self/static/parent in declared type
1362            let declared_fqcn: &Arc<str> = match declared_atom {
1363                Atomic::TNamedObject { fqcn, .. } => fqcn,
1364                Atomic::TSelf { fqcn } => fqcn,
1365                Atomic::TStaticObject { fqcn } => fqcn,
1366                Atomic::TParent { fqcn } => fqcn,
1367                _ => return false,
1368            };
1369
1370            let resolved_declared = codebase.resolve_class_name(file, declared_fqcn.as_ref());
1371            let resolved_actual = codebase.resolve_class_name(file, actual_fqcn.as_ref());
1372
1373            // Self/static always compatible with the class itself
1374            if matches!(
1375                actual_atom,
1376                Atomic::TSelf { .. } | Atomic::TStaticObject { .. }
1377            ) && (resolved_actual == resolved_declared
1378                    || actual_fqcn.as_ref() == declared_fqcn.as_ref()
1379                    || actual_fqcn.as_ref() == resolved_declared.as_str()
1380                    || resolved_actual.as_str() == declared_fqcn.as_ref()
1381                    || codebase.extends_or_implements(actual_fqcn.as_ref(), &resolved_declared)
1382                    || codebase.extends_or_implements(actual_fqcn.as_ref(), declared_fqcn.as_ref())
1383                    || codebase.extends_or_implements(&resolved_actual, &resolved_declared)
1384                    || codebase.extends_or_implements(&resolved_actual, declared_fqcn.as_ref())
1385                    // static(X) is compatible with declared Y if Y extends X
1386                    // (because when called on Y, static = Y which satisfies declared Y)
1387                    || codebase.extends_or_implements(&resolved_declared, actual_fqcn.as_ref())
1388                    || codebase.extends_or_implements(&resolved_declared, &resolved_actual)
1389                    || codebase.extends_or_implements(declared_fqcn.as_ref(), actual_fqcn.as_ref()))
1390            {
1391                return true;
1392            }
1393
1394            // Same class after resolution — check generic type params with variance
1395            let is_same_class = resolved_actual == resolved_declared
1396                || actual_fqcn.as_ref() == declared_fqcn.as_ref()
1397                || actual_fqcn.as_ref() == resolved_declared.as_str()
1398                || resolved_actual.as_str() == declared_fqcn.as_ref();
1399
1400            if is_same_class {
1401                let actual_type_params = match actual_atom {
1402                    Atomic::TNamedObject { type_params, .. } => type_params.as_slice(),
1403                    _ => &[],
1404                };
1405                let declared_type_params = match declared_atom {
1406                    Atomic::TNamedObject { type_params, .. } => type_params.as_slice(),
1407                    _ => &[],
1408                };
1409                if !actual_type_params.is_empty() || !declared_type_params.is_empty() {
1410                    let class_tps = codebase.get_class_template_params(&resolved_declared);
1411                    return return_type_params_compatible(
1412                        actual_type_params,
1413                        declared_type_params,
1414                        &class_tps,
1415                    );
1416                }
1417                return true;
1418            }
1419
1420            // Inheritance check
1421            codebase.extends_or_implements(actual_fqcn.as_ref(), &resolved_declared)
1422                || codebase.extends_or_implements(actual_fqcn.as_ref(), declared_fqcn.as_ref())
1423                || codebase.extends_or_implements(&resolved_actual, &resolved_declared)
1424                || codebase.extends_or_implements(&resolved_actual, declared_fqcn.as_ref())
1425        })
1426    })
1427}
1428
1429/// Check whether generic return type parameters are compatible according to each parameter's
1430/// declared variance. Simpler than the arg-checking version — uses only structural subtyping
1431/// since we don't have access to ExpressionAnalyzer here.
1432fn return_type_params_compatible(
1433    actual_params: &[Union],
1434    declared_params: &[Union],
1435    template_params: &[mir_codebase::storage::TemplateParam],
1436) -> bool {
1437    if actual_params.len() != declared_params.len() {
1438        return true;
1439    }
1440    if actual_params.is_empty() {
1441        return true;
1442    }
1443
1444    for (i, (actual_p, declared_p)) in actual_params.iter().zip(declared_params.iter()).enumerate()
1445    {
1446        let variance = template_params
1447            .get(i)
1448            .map(|tp| tp.variance)
1449            .unwrap_or(mir_types::Variance::Invariant);
1450
1451        let compatible = match variance {
1452            mir_types::Variance::Covariant => {
1453                actual_p.is_subtype_of_simple(declared_p)
1454                    || declared_p.is_mixed()
1455                    || actual_p.is_mixed()
1456            }
1457            mir_types::Variance::Contravariant => {
1458                declared_p.is_subtype_of_simple(actual_p)
1459                    || actual_p.is_mixed()
1460                    || declared_p.is_mixed()
1461            }
1462            mir_types::Variance::Invariant => {
1463                actual_p == declared_p
1464                    || actual_p.is_mixed()
1465                    || declared_p.is_mixed()
1466                    || (actual_p.is_subtype_of_simple(declared_p)
1467                        && declared_p.is_subtype_of_simple(actual_p))
1468            }
1469        };
1470
1471        if !compatible {
1472            return false;
1473        }
1474    }
1475
1476    true
1477}
1478
1479/// Returns true if the declared return type contains template-like types (unknown FQCNs
1480/// without namespace separator that don't exist in the codebase) — we can't validate
1481/// return types against generic type parameters without full template instantiation.
1482fn declared_return_has_template(declared: &Union, codebase: &Codebase) -> bool {
1483    declared.types.iter().any(|atomic| match atomic {
1484        Atomic::TTemplateParam { .. } => true,
1485        // Generic class instantiation (e.g. Result<string, void>) — skip without full template inference.
1486        // Also skip when the named class doesn't exist in the codebase (e.g. type aliases
1487        // that were resolved to a fully-qualified name but aren't real classes).
1488        // Also skip when the type is an interface — concrete implementations may satisfy the
1489        // declared type in ways we don't track (not flagged at default error level).
1490        Atomic::TNamedObject { fqcn, type_params } => {
1491            !type_params.is_empty()
1492                || !codebase.type_exists(fqcn.as_ref())
1493                || codebase.interfaces.contains_key(fqcn.as_ref())
1494        }
1495        Atomic::TArray { value, .. }
1496        | Atomic::TList { value }
1497        | Atomic::TNonEmptyArray { value, .. }
1498        | Atomic::TNonEmptyList { value } => value.types.iter().any(|v| match v {
1499            Atomic::TTemplateParam { .. } => true,
1500            Atomic::TNamedObject { fqcn, .. } => {
1501                !fqcn.contains('\\') && !codebase.type_exists(fqcn.as_ref())
1502            }
1503            _ => false,
1504        }),
1505        _ => false,
1506    })
1507}
1508
1509/// Resolve all TNamedObject FQCNs in a Union using the codebase's file-level imports/namespace.
1510/// Used to fix up `@var` annotation types that were parsed without namespace context.
1511fn resolve_union_for_file(union: Union, codebase: &Codebase, file: &str) -> Union {
1512    let mut result = Union::empty();
1513    result.possibly_undefined = union.possibly_undefined;
1514    result.from_docblock = union.from_docblock;
1515    for atomic in union.types {
1516        let resolved = resolve_atomic_for_file(atomic, codebase, file);
1517        result.types.push(resolved);
1518    }
1519    result
1520}
1521
1522fn is_resolvable_class_name(s: &str) -> bool {
1523    !s.is_empty()
1524        && s.chars()
1525            .all(|c| c.is_alphanumeric() || c == '_' || c == '\\')
1526}
1527
1528fn resolve_atomic_for_file(atomic: Atomic, codebase: &Codebase, file: &str) -> Atomic {
1529    match atomic {
1530        Atomic::TNamedObject { fqcn, type_params } => {
1531            if !is_resolvable_class_name(fqcn.as_ref()) {
1532                return Atomic::TNamedObject { fqcn, type_params };
1533            }
1534            let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1535            Atomic::TNamedObject {
1536                fqcn: resolved.into(),
1537                type_params,
1538            }
1539        }
1540        Atomic::TClassString(Some(cls)) => {
1541            let resolved = codebase.resolve_class_name(file, cls.as_ref());
1542            Atomic::TClassString(Some(resolved.into()))
1543        }
1544        Atomic::TList { value } => Atomic::TList {
1545            value: Box::new(resolve_union_for_file(*value, codebase, file)),
1546        },
1547        Atomic::TNonEmptyList { value } => Atomic::TNonEmptyList {
1548            value: Box::new(resolve_union_for_file(*value, codebase, file)),
1549        },
1550        Atomic::TArray { key, value } => Atomic::TArray {
1551            key: Box::new(resolve_union_for_file(*key, codebase, file)),
1552            value: Box::new(resolve_union_for_file(*value, codebase, file)),
1553        },
1554        Atomic::TSelf { fqcn } if fqcn.is_empty() => {
1555            // Sentinel from docblock parser — leave as-is; caller handles it
1556            Atomic::TSelf { fqcn }
1557        }
1558        other => other,
1559    }
1560}
1561
1562/// Returns true if both actual and declared are array/list types whose value types are
1563/// compatible with FQCN resolution (to avoid short-name vs FQCN mismatches in return types).
1564fn return_arrays_compatible(
1565    actual: &Union,
1566    declared: &Union,
1567    codebase: &Codebase,
1568    file: &str,
1569) -> bool {
1570    actual.types.iter().all(|a_atomic| {
1571        let act_val: &Union = match a_atomic {
1572            Atomic::TArray { value, .. }
1573            | Atomic::TNonEmptyArray { value, .. }
1574            | Atomic::TList { value }
1575            | Atomic::TNonEmptyList { value } => value,
1576            Atomic::TKeyedArray { .. } => return true,
1577            _ => return false,
1578        };
1579
1580        declared.types.iter().any(|d_atomic| {
1581            let dec_val: &Union = match d_atomic {
1582                Atomic::TArray { value, .. }
1583                | Atomic::TNonEmptyArray { value, .. }
1584                | Atomic::TList { value }
1585                | Atomic::TNonEmptyList { value } => value,
1586                _ => return false,
1587            };
1588
1589            act_val.types.iter().all(|av| {
1590                match av {
1591                    Atomic::TNever => return true,
1592                    Atomic::TClassString(Some(av_cls)) => {
1593                        return dec_val.types.iter().any(|dv| match dv {
1594                            Atomic::TClassString(None) | Atomic::TString => true,
1595                            Atomic::TClassString(Some(dv_cls)) => {
1596                                av_cls == dv_cls
1597                                    || codebase
1598                                        .extends_or_implements(av_cls.as_ref(), dv_cls.as_ref())
1599                            }
1600                            _ => false,
1601                        });
1602                    }
1603                    _ => {}
1604                }
1605                let av_fqcn: &Arc<str> = match av {
1606                    Atomic::TNamedObject { fqcn, .. } => fqcn,
1607                    Atomic::TSelf { fqcn } | Atomic::TStaticObject { fqcn } => fqcn,
1608                    Atomic::TClosure { .. } => return true,
1609                    _ => return Union::single(av.clone()).is_subtype_of_simple(dec_val),
1610                };
1611                dec_val.types.iter().any(|dv| {
1612                    let dv_fqcn: &Arc<str> = match dv {
1613                        Atomic::TNamedObject { fqcn, .. } => fqcn,
1614                        Atomic::TClosure { .. } => return true,
1615                        _ => return false,
1616                    };
1617                    if !dv_fqcn.contains('\\') && !codebase.type_exists(dv_fqcn.as_ref()) {
1618                        return true; // template param wildcard
1619                    }
1620                    let res_dec = codebase.resolve_class_name(file, dv_fqcn.as_ref());
1621                    let res_act = codebase.resolve_class_name(file, av_fqcn.as_ref());
1622                    res_dec == res_act
1623                        || codebase.extends_or_implements(av_fqcn.as_ref(), &res_dec)
1624                        || codebase.extends_or_implements(&res_act, &res_dec)
1625                })
1626            })
1627        })
1628    })
1629}