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