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