Skip to main content

tsz_checker/flow/
control_flow.rs

1//! Control Flow Analysis for type narrowing.
2//!
3//! This module provides flow-sensitive type analysis that walks the control flow
4//! graph backwards from identifier usages to determine narrowed types.
5//!
6//! Example:
7//! ```typescript
8//! function foo(x: string | number) {
9//!     if (typeof x === "string") {
10//!         // FlowAnalyzer walks back and sees TRUE_CONDITION (typeof x === "string")
11//!         // Returns: string (narrowed from string | number)
12//!         console.log(x.length);
13//!     } else {
14//!         // FlowAnalyzer sees FALSE_CONDITION
15//!         // Returns: number
16//!         console.log(x.toFixed(2));
17//!     }
18//! }
19//! ```
20
21use crate::query_boundaries::flow_analysis as query;
22use rustc_hash::{FxHashMap, FxHashSet};
23use std::cell::RefCell;
24use std::collections::VecDeque;
25use std::rc::Rc;
26use tsz_binder::BinderState;
27use tsz_binder::{FlowNode, FlowNodeArena, FlowNodeId, SymbolId, flow_flags};
28use tsz_common::interner::Atom;
29use tsz_parser::parser::node::NodeArena;
30use tsz_parser::parser::{NodeIndex, syntax_kind_ext};
31use tsz_scanner::SyntaxKind;
32use tsz_solver::{NarrowingContext, ParamInfo, QueryDatabase, TypeId, TypePredicate};
33
34type FlowCache = FxHashMap<(FlowNodeId, SymbolId, TypeId), TypeId>;
35type ReferenceMatchCache = RefCell<FxHashMap<(u32, u32), bool>>;
36type ReferenceSymbolCache = RefCell<FxHashMap<u32, Option<SymbolId>>>;
37/// Instantiated type predicates from generic call resolutions, keyed by call node index.
38pub(crate) type CallPredicateMap = FxHashMap<u32, (TypePredicate, Vec<ParamInfo>)>;
39
40// Guard against pathological requeue loops in flow traversal.
41const FLOW_STEP_BUDGET_MIN: usize = 1_024;
42const FLOW_STEP_BUDGET_SCALE: usize = 12;
43const FLOW_STEP_BUDGET_MAX: usize = 40_000;
44
45const fn flow_step_budget(flow_node_count: usize) -> usize {
46    let scaled = flow_node_count.saturating_mul(FLOW_STEP_BUDGET_SCALE);
47    if scaled < FLOW_STEP_BUDGET_MIN {
48        FLOW_STEP_BUDGET_MIN
49    } else if scaled > FLOW_STEP_BUDGET_MAX {
50        FLOW_STEP_BUDGET_MAX
51    } else {
52        scaled
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::{
59        FLOW_STEP_BUDGET_MAX, FLOW_STEP_BUDGET_MIN, FLOW_STEP_BUDGET_SCALE, flow_step_budget,
60    };
61
62    #[test]
63    fn flow_step_budget_has_minimum_floor() {
64        assert_eq!(flow_step_budget(0), FLOW_STEP_BUDGET_MIN);
65        assert_eq!(flow_step_budget(1), FLOW_STEP_BUDGET_MIN);
66    }
67
68    #[test]
69    fn flow_step_budget_scales_with_graph_size() {
70        let nodes = FLOW_STEP_BUDGET_MIN / FLOW_STEP_BUDGET_SCALE + 10;
71        assert_eq!(flow_step_budget(nodes), nodes * FLOW_STEP_BUDGET_SCALE);
72    }
73
74    #[test]
75    fn flow_step_budget_has_upper_cap() {
76        assert_eq!(flow_step_budget(usize::MAX), FLOW_STEP_BUDGET_MAX);
77    }
78
79    #[test]
80    fn flow_step_budget_caps_large_graphs() {
81        let nodes = FLOW_STEP_BUDGET_MAX;
82        assert_eq!(flow_step_budget(nodes), FLOW_STEP_BUDGET_MAX);
83    }
84
85    #[test]
86    fn flow_step_budget_caps_large_contention_graphs_earlier() {
87        // Keep pathological full-suite flow walks bounded under worker contention.
88        assert_eq!(flow_step_budget(8_000), FLOW_STEP_BUDGET_MAX);
89    }
90}
91
92// =============================================================================
93// FlowGraph
94// =============================================================================
95
96/// A control flow graph that provides query methods for flow analysis.
97///
98/// This wraps the `FlowNodeArena` and provides convenient methods for querying
99/// flow information during type checking.
100#[derive(Debug)]
101pub struct FlowGraph<'a> {
102    /// Reference to the flow node arena containing all flow nodes
103    arena: &'a FlowNodeArena,
104}
105
106impl<'a> FlowGraph<'a> {
107    /// Create a new `FlowGraph` from a `FlowNodeArena`.
108    pub const fn new(arena: &'a FlowNodeArena) -> Self {
109        Self { arena }
110    }
111
112    /// Get a flow node by ID.
113    pub fn get(&self, id: FlowNodeId) -> Option<&FlowNode> {
114        self.arena.get(id)
115    }
116
117    /// Get the number of flow nodes in the graph.
118    pub const fn len(&self) -> usize {
119        self.arena.len()
120    }
121
122    /// Check if the flow graph is empty.
123    pub const fn is_empty(&self) -> bool {
124        self.arena.is_empty()
125    }
126
127    /// Get the antecedents (predecessors) of a flow node.
128    pub fn antecedents(&self, id: FlowNodeId) -> Vec<FlowNodeId> {
129        self.get(id)
130            .map(|node| node.antecedent.clone())
131            .unwrap_or_default()
132    }
133
134    /// Get the AST node associated with a flow node.
135    pub fn node(&self, id: FlowNodeId) -> NodeIndex {
136        self.get(id).map_or(NodeIndex::NONE, |node| node.node)
137    }
138}
139
140// =============================================================================
141// FlowAnalyzer
142// =============================================================================
143
144/// Flow analyzer for control flow-based type narrowing.
145///
146/// Walks the control flow graph backwards from a reference point to determine
147/// what type narrowing applies at that location.
148pub struct FlowAnalyzer<'a> {
149    pub(crate) arena: &'a NodeArena,
150    pub(crate) binder: &'a BinderState,
151    pub(crate) interner: &'a dyn QueryDatabase,
152    pub(crate) node_types: Option<&'a FxHashMap<u32, TypeId>>,
153    pub(crate) flow_graph: Option<FlowGraph<'a>>,
154    /// Optional cache for flow analysis results to avoid redundant graph traversals
155    pub(crate) flow_cache: Option<&'a RefCell<FlowCache>>,
156    /// Optional `TypeEnvironment` for resolving Lazy types during narrowing
157    pub(crate) type_environment: Option<Rc<RefCell<tsz_solver::TypeEnvironment>>>,
158    /// Cache for switch-reference relevance checks.
159    /// Key: (`switch_expr_node`, `reference_node`) -> whether switch can narrow reference.
160    switch_reference_cache: RefCell<FxHashMap<(u32, u32), bool>>,
161    /// Optional shared switch-reference cache.
162    pub(crate) shared_switch_reference_cache: Option<&'a ReferenceMatchCache>,
163    /// Cache for `is_matching_reference` results.
164    /// Key: (`node_a`, `node_b`) -> whether references match (same symbol/property chain).
165    /// This avoids O(N²) repeated comparisons during flow analysis with many variables.
166    pub(crate) reference_match_cache: ReferenceMatchCache,
167    /// Cache for `reference_symbol` lookups.
168    /// Key: `node` -> resolved symbol (or `None` when not resolvable as a symbol).
169    pub(crate) reference_symbol_cache: ReferenceSymbolCache,
170    /// Optional shared reference-match cache from the checker context.
171    /// When provided, this lets multiple `FlowAnalyzer` instances reuse reference
172    /// equivalence results within the same file check.
173    pub(crate) shared_reference_match_cache: Option<&'a ReferenceMatchCache>,
174    /// Cache numeric atom conversions during a single flow walk.
175    /// Key: normalized f64 bits (with +0 normalized separately from -0).
176    pub(crate) numeric_atom_cache: RefCell<FxHashMap<u64, Atom>>,
177    /// Optional shared numeric atom cache.
178    pub(crate) shared_numeric_atom_cache: Option<&'a RefCell<FxHashMap<u64, Atom>>>,
179    /// Optional shared narrowing cache.
180    pub(crate) narrowing_cache: Option<&'a tsz_solver::NarrowingCache>,
181    /// Instantiated type predicates from generic call resolutions.
182    /// Keyed by call expression node index.
183    pub(crate) call_type_predicates: Option<&'a CallPredicateMap>,
184    /// Reusable buffers for flow analysis.
185    pub(crate) flow_worklist: Option<&'a RefCell<VecDeque<(FlowNodeId, TypeId)>>>,
186    pub(crate) flow_in_worklist: Option<&'a RefCell<FxHashSet<FlowNodeId>>>,
187    pub(crate) flow_visited: Option<&'a RefCell<FxHashSet<FlowNodeId>>>,
188    pub(crate) flow_results: Option<&'a RefCell<FxHashMap<FlowNodeId, TypeId>>>,
189}
190
191#[derive(Clone, Copy, Debug)]
192pub(crate) enum PropertyKey {
193    Atom(Atom),
194    Index(usize),
195}
196
197#[derive(Clone)]
198pub(crate) struct PredicateSignature {
199    pub(crate) predicate: TypePredicate,
200    pub(crate) params: Vec<ParamInfo>,
201}
202
203impl<'a> FlowAnalyzer<'a> {
204    /// Create a new `FlowAnalyzer`.
205    pub fn new(
206        arena: &'a NodeArena,
207        binder: &'a BinderState,
208        interner: &'a dyn QueryDatabase,
209    ) -> Self {
210        let flow_graph = Some(FlowGraph::new(&binder.flow_nodes));
211        Self {
212            arena,
213            binder,
214            interner,
215            node_types: None,
216            flow_graph,
217            flow_cache: None,
218            type_environment: None,
219            switch_reference_cache: RefCell::new(FxHashMap::default()),
220            shared_switch_reference_cache: None,
221            reference_match_cache: RefCell::new(FxHashMap::default()),
222            reference_symbol_cache: RefCell::new(FxHashMap::default()),
223            shared_reference_match_cache: None,
224            numeric_atom_cache: RefCell::new(FxHashMap::default()),
225            shared_numeric_atom_cache: None,
226            narrowing_cache: None,
227            call_type_predicates: None,
228            flow_worklist: None,
229            flow_in_worklist: None,
230            flow_visited: None,
231            flow_results: None,
232        }
233    }
234
235    pub fn with_node_types(
236        arena: &'a NodeArena,
237        binder: &'a BinderState,
238        interner: &'a dyn QueryDatabase,
239        node_types: &'a FxHashMap<u32, TypeId>,
240    ) -> Self {
241        let flow_graph = Some(FlowGraph::new(&binder.flow_nodes));
242        Self {
243            arena,
244            binder,
245            interner,
246            node_types: Some(node_types),
247            flow_graph,
248            flow_cache: None,
249            type_environment: None,
250            switch_reference_cache: RefCell::new(FxHashMap::default()),
251            shared_switch_reference_cache: None,
252            reference_match_cache: RefCell::new(FxHashMap::default()),
253            reference_symbol_cache: RefCell::new(FxHashMap::default()),
254            shared_reference_match_cache: None,
255            numeric_atom_cache: RefCell::new(FxHashMap::default()),
256            shared_numeric_atom_cache: None,
257            narrowing_cache: None,
258            call_type_predicates: None,
259            flow_worklist: None,
260            flow_in_worklist: None,
261            flow_visited: None,
262            flow_results: None,
263        }
264    }
265
266    /// Set the flow analysis cache to avoid redundant graph traversals.
267    pub const fn with_flow_cache(
268        mut self,
269        cache: &'a RefCell<FxHashMap<(FlowNodeId, SymbolId, TypeId), TypeId>>,
270    ) -> Self {
271        self.flow_cache = Some(cache);
272        self
273    }
274
275    /// Set a shared reference-match cache used by `is_matching_reference`.
276    pub const fn with_reference_match_cache(mut self, cache: &'a ReferenceMatchCache) -> Self {
277        self.shared_reference_match_cache = Some(cache);
278        self
279    }
280
281    /// Set a shared switch-reference cache.
282    pub const fn with_switch_reference_cache(mut self, cache: &'a ReferenceMatchCache) -> Self {
283        self.shared_switch_reference_cache = Some(cache);
284        self
285    }
286
287    /// Set a shared narrowing cache.
288    pub const fn with_narrowing_cache(mut self, cache: &'a tsz_solver::NarrowingCache) -> Self {
289        self.narrowing_cache = Some(cache);
290        self
291    }
292
293    /// Set instantiated call type predicates from generic call resolutions.
294    pub const fn with_call_type_predicates(mut self, predicates: &'a CallPredicateMap) -> Self {
295        self.call_type_predicates = Some(predicates);
296        self
297    }
298
299    /// Set a shared numeric atom cache.
300    pub const fn with_numeric_atom_cache(
301        mut self,
302        cache: &'a RefCell<FxHashMap<u64, Atom>>,
303    ) -> Self {
304        self.shared_numeric_atom_cache = Some(cache);
305        self
306    }
307
308    /// Set reusable flow buffers.
309    pub const fn with_flow_buffers(
310        mut self,
311        worklist: &'a RefCell<VecDeque<(FlowNodeId, TypeId)>>,
312        in_worklist: &'a RefCell<FxHashSet<FlowNodeId>>,
313        visited: &'a RefCell<FxHashSet<FlowNodeId>>,
314        results: &'a RefCell<FxHashMap<FlowNodeId, TypeId>>,
315    ) -> Self {
316        self.flow_worklist = Some(worklist);
317        self.flow_in_worklist = Some(in_worklist);
318        self.flow_visited = Some(visited);
319        self.flow_results = Some(results);
320        self
321    }
322
323    pub(crate) fn is_assignable_to(&self, source: TypeId, target: TypeId) -> bool {
324        if let Some(env) = &self.type_environment {
325            return query::is_assignable_with_env(
326                self.interner,
327                &env.borrow(),
328                source,
329                target,
330                false,
331            );
332        }
333        query::is_assignable(self.interner, source, target)
334    }
335
336    pub(crate) fn is_assignable_to_strict_null(&self, source: TypeId, target: TypeId) -> bool {
337        if let Some(env) = &self.type_environment {
338            return query::is_assignable_with_env(
339                self.interner,
340                &env.borrow(),
341                source,
342                target,
343                true,
344            );
345        }
346        query::is_assignable_strict_null(self.interner, source, target)
347    }
348
349    /// Set the `TypeEnvironment` for resolving Lazy types during narrowing.
350    pub fn with_type_environment(
351        mut self,
352        type_env: Rc<RefCell<tsz_solver::TypeEnvironment>>,
353    ) -> Self {
354        self.type_environment = Some(type_env);
355        self
356    }
357
358    /// Check if the switch expression is the literal `true` keyword.
359    /// `switch(true)` is a pattern where each case clause acts as an independent
360    /// type guard condition, not a comparison against the switch expression.
361    fn is_switch_true(&self, switch_expr: NodeIndex) -> bool {
362        self.arena
363            .get(switch_expr)
364            .is_some_and(|node| node.kind == SyntaxKind::TrueKeyword as u16)
365    }
366
367    #[inline]
368    fn switch_can_affect_reference(&self, switch_expr: NodeIndex, reference: NodeIndex) -> bool {
369        // switch(true) can narrow any reference — each case expression is an
370        // independent condition (like an if-else chain).
371        if self.is_switch_true(switch_expr) {
372            return true;
373        }
374
375        let key = (switch_expr.0, reference.0);
376        if let Some(shared) = self.shared_switch_reference_cache
377            && let Some(&cached) = shared.borrow().get(&key)
378        {
379            return cached;
380        }
381        if let Some(&cached) = self.switch_reference_cache.borrow().get(&key) {
382            return cached;
383        }
384
385        let affects = self.is_matching_reference(switch_expr, reference)
386            || self
387                .discriminant_property_info(switch_expr, reference)
388                .is_some_and(|(_, _, base)| self.is_matching_reference(base, reference))
389            // switch (typeof x) narrows x through typeof comparison
390            || self.is_typeof_target(switch_expr, reference);
391
392        if let Some(shared) = self.shared_switch_reference_cache {
393            shared.borrow_mut().insert(key, affects);
394        }
395        self.switch_reference_cache
396            .borrow_mut()
397            .insert(key, affects);
398        affects
399    }
400
401    /// Get a reference to the flow graph.
402    pub const fn flow_graph(&self) -> Option<&FlowGraph<'a>> {
403        self.flow_graph.as_ref()
404    }
405
406    /// Get the narrowed type of a symbol at a specific flow node.
407    ///
408    /// This walks backwards through the flow graph, applying narrowing operations
409    /// when it encounters condition nodes.
410    pub fn get_flow_type(
411        &self,
412        reference: NodeIndex,
413        initial_type: TypeId,
414        flow_node: FlowNodeId,
415    ) -> TypeId {
416        if flow_node.is_none() {
417            return initial_type;
418        }
419
420        // Resolve symbol for caching purposes.
421        // Fallback to reference_symbol for non-identifier references (e.g. some
422        // qualified/member references) so repeated flow queries can share cache
423        // entries instead of using per-node synthetic symbols.
424        let symbol_id = self
425            .binder
426            .resolve_identifier(self.arena, reference)
427            .or_else(|| self.reference_symbol(reference));
428
429        self.check_flow(
430            reference,
431            initial_type,
432            flow_node,
433            &mut Vec::new(),
434            symbol_id,
435        )
436    }
437
438    /// Check if a reference is definitely assigned at a specific flow node.
439    pub fn is_definitely_assigned(&self, reference: NodeIndex, flow_node: FlowNodeId) -> bool {
440        if flow_node.is_none() {
441            return true;
442        }
443
444        let mut visited = Vec::new();
445        let mut cache = FxHashMap::default();
446        self.check_definite_assignment(reference, flow_node, &mut visited, &mut cache)
447    }
448
449    /// Analyze a loop using fixed-point iteration to determine the stable type of a variable.
450    ///
451    /// This implements TypeScript's loop flow analysis where the type of a variable
452    /// at the start of a loop depends on its type at the end (back-edge). We iterate
453    /// until the type stabilizes (reaches a fixed point).
454    ///
455    /// # Arguments
456    /// * `loop_flow_id` - The `FlowNodeId` of the `LOOP_LABEL` (for cache key)
457    /// * `loop_flow` - The `LOOP_LABEL` flow node
458    /// * `reference` - The variable reference we're analyzing
459    /// * `entry_type` - The type entering the loop (from antecedent[0])
460    /// * `initial_type` - The declared type of the variable (for widening)
461    /// * `symbol_id` - The symbol ID (for cache key)
462    ///
463    /// # Returns
464    /// The stabilized type after fixed-point iteration
465    fn analyze_loop_fixed_point(
466        &self,
467        loop_flow_id: FlowNodeId,
468        loop_flow: &FlowNode,
469        reference: NodeIndex,
470        entry_type: TypeId,
471        initial_type: TypeId,
472        symbol_id: Option<SymbolId>,
473    ) -> TypeId {
474        const MAX_ITERATIONS: usize = 5;
475
476        // For const symbols, no fixed-point needed - they can't be reassigned
477        if let Some(sym_id) = symbol_id
478            && self.is_const_symbol(sym_id)
479        {
480            return entry_type;
481        }
482
483        // Without a symbol_id we cannot inject cache entries to break the
484        // get_flow_type → check_flow → LOOP_LABEL → analyze_loop_fixed_point
485        // recursion cycle.  This happens for property-access references
486        // (e.g. `fns.length`) whose base symbol is tracked separately.
487        // Returning the entry type is safe because property access expressions
488        // are never reassigned inside loops.
489        if symbol_id.is_none() {
490            return entry_type;
491        }
492
493        // If there's only one antecedent (just the entry, no back-edges), no iteration needed
494        if loop_flow.antecedent.len() <= 1 {
495            return entry_type;
496        }
497
498        let mut current_type = entry_type;
499
500        // Fixed-point iteration: union entry type with all back-edge types
501        for _iteration in 0..MAX_ITERATIONS {
502            let prev_type = current_type;
503
504            // CRITICAL FIX: Inject current assumption into cache to break infinite recursion
505            // Without this, get_flow_type -> check_flow -> LOOP_LABEL -> analyze_loop_fixed_point
506            // would cause stack overflow
507            //
508            // This tells the recursive traversal: "If you hit this loop header again,
509            // assume its type is current_type and stop"
510            //
511            // We inject under TWO keys: one with initial_type (for the outer check_flow's
512            // cache lookup) and one with current_type (for the inner back-edge traversal
513            // which uses current_type as its initial_type).
514            if let (Some(sym_id), Some(cache)) = (symbol_id, self.flow_cache) {
515                let key = (loop_flow_id, sym_id, initial_type);
516                cache.borrow_mut().insert(key, current_type);
517                if current_type != initial_type {
518                    let inner_key = (loop_flow_id, sym_id, current_type);
519                    cache.borrow_mut().insert(inner_key, current_type);
520                }
521            }
522
523            // Union entry type with all back-edge types (antecedents[1+])
524            for &back_edge in loop_flow.antecedent.iter().skip(1) {
525                // Use current_type (the current loop assumption) as the initial type
526                // for back-edge traversal instead of the declared type. This ensures
527                // narrowing inside the loop body uses the loop's computed type, not
528                // the full declared type. E.g., if declared type is string|number|boolean
529                // but the loop only assigns string and number, narrowing typeof !== "number"
530                // should give string (not string|boolean).
531                let back_edge_type = self.get_flow_type(reference, current_type, back_edge);
532
533                // Union current type with back-edge type
534                current_type =
535                    query::union_types(self.interner, vec![current_type, back_edge_type]);
536            }
537
538            // Check if we've reached a fixed point (type stopped changing)
539            if current_type == prev_type {
540                return current_type;
541            }
542        }
543
544        // Fixed point not reached within iteration limit
545        // Conservative widening: return union of entry type and initial declared type
546        // This matches TypeScript's behavior for complex loops
547        let widened = query::union_types(self.interner, vec![entry_type, initial_type]);
548
549        // Update cache with final widened result
550        if let (Some(sym_id), Some(cache)) = (symbol_id, self.flow_cache) {
551            let key = (loop_flow_id, sym_id, initial_type);
552            cache.borrow_mut().insert(key, widened);
553        }
554
555        widened
556    }
557
558    /// Iterative flow graph traversal using a worklist algorithm.
559    ///
560    /// This replaces the recursive implementation to prevent stack overflow
561    /// on deeply nested control flow structures. Uses a `VecDeque` worklist with
562    /// cycle detection to process flow nodes iteratively.
563    pub(crate) fn check_flow(
564        &self,
565        reference: NodeIndex,
566        initial_type: TypeId,
567        flow_id: FlowNodeId,
568        _visited: &mut Vec<FlowNodeId>,
569        symbol_id: Option<SymbolId>,
570    ) -> TypeId {
571        // Reusable buffers to avoid heap allocations in hot path.
572        // Use try_borrow_mut to handle re-entrancy safely (e.g. during bidirectional narrowing).
573        let mut local_worklist = VecDeque::new();
574        let mut local_in_worklist = FxHashSet::default();
575        let mut local_visited = FxHashSet::default();
576        let mut local_results = FxHashMap::default();
577
578        // Borrow shared buffers if available and NOT already borrowed, otherwise fallback to local ones
579        let mut worklist_borrow = self.flow_worklist.and_then(|b| b.try_borrow_mut().ok());
580        let mut in_worklist_borrow = self.flow_in_worklist.and_then(|b| b.try_borrow_mut().ok());
581        let mut visited_borrow = self.flow_visited.and_then(|b| b.try_borrow_mut().ok());
582        let mut results_borrow = self.flow_results.and_then(|b| b.try_borrow_mut().ok());
583
584        let worklist = worklist_borrow
585            .as_deref_mut()
586            .unwrap_or(&mut local_worklist);
587        let in_worklist = in_worklist_borrow
588            .as_deref_mut()
589            .unwrap_or(&mut local_in_worklist);
590        let visited = visited_borrow.as_deref_mut().unwrap_or(&mut local_visited);
591        let results = results_borrow.as_deref_mut().unwrap_or(&mut local_results);
592
593        // Clear buffers for reuse
594        worklist.clear();
595        in_worklist.clear();
596        visited.clear();
597        results.clear();
598
599        // CRITICAL: Check if initial type contains type parameters ONCE, outside the loop.
600        // This prevents caching generic types across different instantiations.
601        // See: https://github.com/microsoft/TypeScript/issues/9998
602        let initial_has_type_params = query::contains_type_parameters(self.interner, initial_type);
603
604        // Use a synthetic cache symbol for references that don't resolve to a symbol
605        // (for example complex/property references). This enables cache reuse while
606        // keeping symbol-backed keys disjoint.
607        let cache_symbol = symbol_id.unwrap_or(SymbolId(reference.0.wrapping_add(1) | 0x8000_0000));
608
609        // Initialize worklist with the entry point
610        worklist.push_back((flow_id, initial_type));
611        in_worklist.insert(flow_id);
612        let step_budget = flow_step_budget(self.binder.flow_nodes.len());
613        let mut steps = 0usize;
614
615        // Process worklist until empty
616        while let Some((current_flow, current_type)) = worklist.pop_front() {
617            steps += 1;
618            if steps > step_budget {
619                // Bail out conservatively to avoid unbounded traversal in pathological CFGs.
620                return results.get(&flow_id).copied().unwrap_or(initial_type);
621            }
622            in_worklist.remove(&current_flow);
623
624            // Check global cache first to avoid redundant traversals.
625            // Skip cache for SWITCH_CLAUSE nodes — they must be processed to
626            // schedule antecedents and apply narrowing.
627            let (is_switch_clause, is_loop_label_node) =
628                if let Some(flow) = self.binder.flow_nodes.get(current_flow) {
629                    (
630                        flow.has_any_flags(flow_flags::SWITCH_CLAUSE),
631                        flow.has_any_flags(flow_flags::LOOP_LABEL),
632                    )
633                } else {
634                    (false, false)
635                };
636
637            // Use cache if: 1) not a switch clause, AND
638            // 2) either initial type is concrete OR this is a loop label.
639            // Loop labels MUST always check cache because analyze_loop_fixed_point
640            // injects entries as a recursion guard — skipping the check causes
641            // stack overflow when types contain type parameters.
642            if !is_switch_clause
643                && (!initial_has_type_params || is_loop_label_node)
644                && let Some(cache) = self.flow_cache
645            {
646                let key = (current_flow, cache_symbol, initial_type);
647                if let Some(&cached_type) = cache.borrow().get(&key) {
648                    // Use cached result and skip processing this node
649                    results.insert(current_flow, cached_type);
650                    visited.insert(current_flow);
651                    continue;
652                }
653            }
654
655            // Skip if we've already finalized this node
656            if visited.contains(&current_flow) {
657                continue;
658            }
659
660            let Some(flow) = self.binder.flow_nodes.get(current_flow) else {
661                // Flow node doesn't exist - use the type we have
662                results.insert(current_flow, current_type);
663                visited.insert(current_flow);
664                continue;
665            };
666            // Check if this is a merge point that needs all antecedents processed first
667            let is_switch_fallthrough =
668                flow.has_any_flags(flow_flags::SWITCH_CLAUSE) && flow.antecedent.len() > 1;
669            let is_loop_header = flow.has_any_flags(flow_flags::LOOP_LABEL);
670            let is_call = flow.has_any_flags(flow_flags::CALL);
671            // Note: ARRAY_MUTATION merge point check is handled below since we need to check
672            // if the mutation actually affects the reference we're analyzing
673            let is_merge_point = flow
674                .has_any_flags(flow_flags::BRANCH_LABEL | flow_flags::LOOP_LABEL)
675                || is_switch_fallthrough
676                || is_call; // CRITICAL: CALL nodes need antecedent for assertion functions
677
678            if is_merge_point && !flow.antecedent.is_empty() {
679                // Some flow graphs can contain self-antecedent edges on merge nodes.
680                // Treat self-edges as already satisfied to avoid requeueing the same
681                // node forever before it can be finalized.
682                let mut all_ready = true;
683                let mut check_antecedent_ready = |ant: FlowNodeId| {
684                    if ant != current_flow && !visited.contains(&ant) && !results.contains_key(&ant)
685                    {
686                        all_ready = false;
687                    }
688                };
689                if is_loop_header {
690                    if let Some(&ant) = flow.antecedent.first() {
691                        check_antecedent_ready(ant);
692                    }
693                } else {
694                    // BRANCH/SWITCH/CALL merge points check all antecedents.
695                    for &ant in &flow.antecedent {
696                        check_antecedent_ready(ant);
697                    }
698                }
699
700                if !all_ready {
701                    // Schedule unprocessed antecedents to be processed FIRST (push_front).
702                    let mut schedule_antecedent = |ant: FlowNodeId| {
703                        if ant == current_flow {
704                            return;
705                        }
706                        if !visited.contains(&ant)
707                            && !results.contains_key(&ant)
708                            && !in_worklist.contains(&ant)
709                        {
710                            worklist.push_front((ant, current_type));
711                            in_worklist.insert(ant);
712                        }
713                    };
714                    if is_loop_header {
715                        if let Some(&ant) = flow.antecedent.first() {
716                            schedule_antecedent(ant);
717                        }
718                    } else {
719                        for &ant in &flow.antecedent {
720                            schedule_antecedent(ant);
721                        }
722                    }
723                    // Re-add self to the END of worklist to process after antecedents
724                    if !in_worklist.contains(&current_flow) {
725                        worklist.push_back((current_flow, current_type));
726                        in_worklist.insert(current_flow);
727                    }
728                    continue;
729                }
730            }
731
732            // Process this flow node based on its flags
733            let result_type = if flow.has_any_flags(flow_flags::BRANCH_LABEL) {
734                // Branch label - union types from all antecedents
735                if flow.antecedent.is_empty() {
736                    current_type
737                } else {
738                    // Add all antecedents to worklist
739                    for &ant in &flow.antecedent {
740                        if !in_worklist.contains(&ant) && !visited.contains(&ant) {
741                            worklist.push_back((ant, current_type));
742                            in_worklist.insert(ant);
743                        }
744                    }
745                    current_type // Will be updated when antecedents are processed
746                }
747            } else if flow.has_any_flags(flow_flags::LOOP_LABEL) {
748                // CRITICAL FIX: Implement proper fixed-point iteration for loops
749                //
750                // Previous implementation: Simple mutation check (unreliable)
751                // New implementation: Fixed-point iteration that unions entry type with back-edge types
752                //
753                // Fixed-Point Algorithm:
754                // 1. Start with entry type (antecedent[0] - before the loop)
755                // 2. Get types at all back-edges (antecedents[1+] - continue/end of body)
756                // 3. Union entry type with all back-edge types
757                // 4. Repeat until type stabilizes (max 5 iterations)
758                // 5. If not stabilized, widen to union(entry, initial)
759                //
760                // This matches TypeScript's behavior where variables in loops have
761                // types that depend on both the entry condition and assignments within the loop.
762
763                let entry_type = if let Some(&ant) = flow.antecedent.first() {
764                    // Ensure entry is processed (is_merge_point logic guarantees this)
765                    *results.get(&ant).unwrap_or(&current_type)
766                } else {
767                    current_type
768                };
769
770                // Use fixed-point iteration to determine stable loop type
771                self.analyze_loop_fixed_point(
772                    current_flow,
773                    flow,
774                    reference,
775                    entry_type,
776                    initial_type,
777                    symbol_id,
778                )
779            } else if flow.has_any_flags(flow_flags::CONDITION) {
780                // Condition node - apply narrowing
781                // CRITICAL: For else-if chains, the antecedent is a CONDITION node
782                // from the outer if's false branch. We must wait for it to be computed
783                // so we narrow from the already-narrowed type, not the original type.
784                let (pre_type, antecedent_id) = if let Some(&ant) = flow.antecedent.first() {
785                    if let Some(&ant_type) = results.get(&ant) {
786                        // Antecedent already computed — use its narrowed type
787                        (ant_type, ant)
788                    } else if !visited.contains(&ant) {
789                        // Antecedent not yet computed — defer if it could carry
790                        // narrowing info we need:
791                        //   CONDITION: else-if chains (nested type guards)
792                        //   CALL: assertion functions
793                        //   LOOP_LABEL: loop fixed-point analysis (incomplete types)
794                        //   BRANCH_LABEL: merges after if-return that carry narrowed types
795                        //   ASSIGNMENT: may chain through from narrowing antecedents
796                        let ant_flags = self
797                            .binder
798                            .flow_nodes
799                            .get(ant)
800                            .map(|f| f.flags)
801                            .unwrap_or(0);
802                        let ant_needs_defer = (ant_flags & flow_flags::CONDITION) != 0
803                            || (ant_flags & flow_flags::CALL) != 0
804                            || (ant_flags & flow_flags::LOOP_LABEL) != 0
805                            || (ant_flags & flow_flags::BRANCH_LABEL) != 0;
806                        if ant_needs_defer {
807                            if !in_worklist.contains(&ant) {
808                                worklist.push_front((ant, current_type));
809                                in_worklist.insert(ant);
810                            }
811                            if !in_worklist.contains(&current_flow) {
812                                worklist.push_back((current_flow, current_type));
813                                in_worklist.insert(current_flow);
814                            }
815                            continue;
816                        }
817                        (current_type, ant)
818                    } else {
819                        // Antecedent visited but no result — use current_type
820                        (current_type, ant)
821                    }
822                } else {
823                    (current_type, FlowNodeId::NONE)
824                };
825
826                let is_true_branch = flow.has_any_flags(flow_flags::TRUE_CONDITION);
827                self.narrow_type_by_condition(
828                    pre_type,
829                    flow.node,
830                    reference,
831                    is_true_branch,
832                    antecedent_id,
833                )
834            } else if flow.has_any_flags(flow_flags::SWITCH_CLAUSE) {
835                // CRITICAL FIX: Schedule antecedent 0 (switch header) for traversal
836                // Fallthrough cases are handled by the is_merge_point block above,
837                // but single-clause cases need this to continue traversal.
838                if let Some(&ant) = flow.antecedent.first()
839                    && !in_worklist.contains(&ant)
840                    && !visited.contains(&ant)
841                {
842                    worklist.push_back((ant, current_type));
843                    in_worklist.insert(ant);
844                }
845
846                // Switch clause - apply switch-specific narrowing
847                self.handle_switch_clause_iterative(reference, current_type, flow, results)
848            } else if flow.has_any_flags(flow_flags::ASSIGNMENT) {
849                // OPTIMIZATION: Quick symbol-based filtering before expensive AST comparison.
850                // If we have a resolved symbol and the assignment's target has a different symbol,
851                // we can skip this assignment entirely. This turns O(N²) into O(N) for cases like
852                // many independent variable assignments.
853                let targets_reference = if let Some(target_sym) = symbol_id {
854                    // Get the assignment target's symbol (O(1) lookup)
855                    let assignment_sym = self.reference_symbol(flow.node);
856                    if assignment_sym.is_some() && assignment_sym != Some(target_sym) {
857                        // Symbols differ - this assignment cannot target our reference
858                        false
859                    } else {
860                        // Same symbol or couldn't determine - do full check
861                        self.assignment_targets_reference_node(flow.node, reference)
862                    }
863                } else {
864                    // No symbol ID - must do full check
865                    self.assignment_targets_reference_node(flow.node, reference)
866                };
867
868                if targets_reference {
869                    // CRITICAL FIX: Skip "killing definition" narrowing for ANY and ERROR types only
870                    // These types should preserve their identity across assignments to match tsc behavior
871                    //
872                    // IMPORTANT: unknown is NOT included here because it SHOULD be narrowed by assignments
873                    // Example: let x: unknown; x = 123; should narrow x to number
874                    //
875                    // any absorbs assignments (stays any)
876                    // error persists to prevent cascading errors
877                    if initial_type != TypeId::ANY && initial_type != TypeId::ERROR {
878                        // Check if this is a destructuring assignment (widens literals to primitives)
879                        let is_destructuring = self.is_destructuring_assignment(flow.node);
880
881                        // CRITICAL FIX: Try to get assigned type for ALL assignments, including destructuring
882                        // Previously: Only direct assignments (x = ...) worked
883                        // Now: Destructuring ([x] = ...) also works because get_assigned_type handles it
884                        if let Some(assigned_type) =
885                            self.get_assigned_type(flow.node, reference, is_destructuring)
886                        {
887                            // Killing definition: replace type with RHS type and stop traversal
888                            self.narrow_assignment(initial_type, assigned_type)
889                        } else {
890                            // If we can't resolve the RHS type, conservatively return declared type
891                            // The value HAS changed, so we can't continue to antecedent
892                            current_type
893                        }
894                    } else {
895                        // For any/error types: Don't apply narrowing - continue to antecedent
896                        // This allows condition narrowing (typeof guards) to still work
897                        if let Some(&ant) = flow.antecedent.first() {
898                            if !in_worklist.contains(&ant) && !visited.contains(&ant) {
899                                worklist.push_back((ant, current_type));
900                                in_worklist.insert(ant);
901                            }
902                            *results.get(&ant).unwrap_or(&current_type)
903                        } else {
904                            current_type
905                        }
906                    }
907                } else if self.assignment_affects_reference_node(flow.node, reference) {
908                    // Two sub-cases of "affects reference":
909                    // 1. Base reassignment (obj = ... affects obj.prop): clears narrowing
910                    // 2. Property mutation (obj.prop.x = ... affects obj.prop): preserves narrowing
911                    //
912                    // Check if the assignment targets a BASE of the reference. If so,
913                    // the reference value may have changed entirely and narrowing is invalid.
914                    let is_base_reassignment =
915                        self.assignment_targets_base_of_reference(flow.node, reference);
916
917                    if is_base_reassignment {
918                        // Base was reassigned — narrowing is invalidated.
919                        // Return initial (declared) type.
920                        if let Some(&ant) = flow.antecedent.first()
921                            && !in_worklist.contains(&ant)
922                            && !visited.contains(&ant)
923                        {
924                            worklist.push_back((ant, current_type));
925                            in_worklist.insert(ant);
926                        }
927                        current_type
928                    } else {
929                        // Property mutation — preserve narrowing from antecedent.
930                        // Must defer when antecedent carries narrowing (CONDITION/CALL)
931                        // and hasn't been computed yet, otherwise we lose typeof narrowing.
932                        if let Some(&ant) = flow.antecedent.first() {
933                            if let Some(&ant_type) = results.get(&ant) {
934                                ant_type
935                            } else if !visited.contains(&ant) {
936                                let ant_needs_defer =
937                                    self.binder.flow_nodes.get(ant).is_some_and(|f| {
938                                        f.has_any_flags(flow_flags::CONDITION | flow_flags::CALL)
939                                    });
940                                if ant_needs_defer {
941                                    if !in_worklist.contains(&ant) {
942                                        worklist.push_front((ant, current_type));
943                                        in_worklist.insert(ant);
944                                    }
945                                    if !in_worklist.contains(&current_flow) {
946                                        worklist.push_back((current_flow, current_type));
947                                        in_worklist.insert(current_flow);
948                                    }
949                                    continue;
950                                }
951                                if !in_worklist.contains(&ant) {
952                                    worklist.push_back((ant, current_type));
953                                    in_worklist.insert(ant);
954                                }
955                                *results.get(&ant).unwrap_or(&current_type)
956                            } else {
957                                current_type
958                            }
959                        } else {
960                            current_type
961                        }
962                    }
963                } else {
964                    // This assignment doesn't affect our reference — pass through to antecedent.
965                    // CRITICAL: If the antecedent carries narrowing info and hasn't been processed
966                    // yet, we must defer to avoid losing narrowing. Without this, the worklist
967                    // may process this ASSIGNMENT before its antecedent chain is resolved, using
968                    // the un-narrowed type. This applies to CONDITION nodes (which directly
969                    // narrow) and CALL nodes (which are merge points whose own antecedents may
970                    // carry narrowing from conditions).
971                    if let Some(&ant) = flow.antecedent.first() {
972                        if let Some(&ant_type) = results.get(&ant) {
973                            // Antecedent already computed — use its result
974                            ant_type
975                        } else if !visited.contains(&ant) {
976                            let ant_needs_defer =
977                                self.binder.flow_nodes.get(ant).is_some_and(|f| {
978                                    f.has_any_flags(
979                                        flow_flags::CONDITION
980                                            | flow_flags::CALL
981                                            | flow_flags::BRANCH_LABEL,
982                                    )
983                                });
984                            if ant_needs_defer {
985                                if !in_worklist.contains(&ant) {
986                                    worklist.push_front((ant, current_type));
987                                    in_worklist.insert(ant);
988                                }
989                                if !in_worklist.contains(&current_flow) {
990                                    worklist.push_back((current_flow, current_type));
991                                    in_worklist.insert(current_flow);
992                                }
993                                continue;
994                            }
995                            if !in_worklist.contains(&ant) {
996                                worklist.push_back((ant, current_type));
997                                in_worklist.insert(ant);
998                            }
999                            *results.get(&ant).unwrap_or(&current_type)
1000                        } else {
1001                            current_type
1002                        }
1003                    } else {
1004                        current_type
1005                    }
1006                }
1007            } else if flow.has_any_flags(flow_flags::ARRAY_MUTATION) {
1008                // Array mutation
1009                let node = match self.arena.get(flow.node) {
1010                    Some(n) => n,
1011                    None => {
1012                        results.insert(current_flow, current_type);
1013                        visited.insert(current_flow);
1014                        continue;
1015                    }
1016                };
1017                let call = match self.arena.get_call_expr(node) {
1018                    Some(c) => c,
1019                    None => {
1020                        results.insert(current_flow, current_type);
1021                        visited.insert(current_flow);
1022                        continue;
1023                    }
1024                };
1025
1026                // Check if this array mutation affects our reference
1027                let affects_ref = self.array_mutation_affects_reference(call, reference);
1028
1029                // For affected references, ARRAY_MUTATION acts as a merge point to preserve narrowing
1030                let needs_antecedent = affects_ref && !flow.antecedent.is_empty();
1031
1032                if needs_antecedent {
1033                    // Check if antecedent is ready (similar to merge point logic)
1034                    if let Some(&ant) = flow.antecedent.first() {
1035                        if !visited.contains(&ant) && !results.contains_key(&ant) {
1036                            // Antecedent not ready - schedule it and defer self
1037                            if !in_worklist.contains(&ant) {
1038                                worklist.push_front((ant, current_type));
1039                                in_worklist.insert(ant);
1040                            }
1041                            if !in_worklist.contains(&current_flow) {
1042                                worklist.push_back((current_flow, current_type));
1043                                in_worklist.insert(current_flow);
1044                            }
1045                            continue;
1046                        }
1047                        // Antecedent is ready - get its result
1048                        *results.get(&ant).unwrap_or(&current_type)
1049                    } else {
1050                        current_type
1051                    }
1052                } else if affects_ref {
1053                    // For local variables, TypeScript preserves narrowing across method calls
1054                    current_type
1055                } else if let Some(&ant) = flow.antecedent.first() {
1056                    if !in_worklist.contains(&ant) && !visited.contains(&ant) {
1057                        worklist.push_back((ant, current_type));
1058                        in_worklist.insert(ant);
1059                    }
1060                    *results.get(&ant).unwrap_or(&current_type)
1061                } else {
1062                    current_type
1063                }
1064            } else if flow.has_any_flags(flow_flags::CALL) {
1065                // Call expression - check for type predicates
1066                self.handle_call_iterative(reference, current_type, flow, results)
1067            } else if flow.has_any_flags(flow_flags::START) {
1068                // Start node - check if we're crossing a closure boundary
1069                // For mutable variables (let/var), we cannot trust narrowing from outer scope
1070                // because the closure may capture the variable and it could be mutated.
1071                // For const variables, narrowing is preserved (they're immutable).
1072                let outer_flow_id = flow.antecedent.first().copied().or_else(|| {
1073                    // START with no antecedents - try to find outer flow via node_flow map
1074                    if flow.node.is_some() {
1075                        self.binder.node_flow.get(&flow.node.0).copied()
1076                    } else {
1077                        None
1078                    }
1079                });
1080
1081                if let Some(outer_flow) = outer_flow_id {
1082                    if self.is_mutable_variable(reference) && self.is_captured_variable(reference) {
1083                        // Captured mutable variable - cannot use narrowing from outer scope
1084                        initial_type
1085                    } else {
1086                        // Const or local variable - preserve narrowing from outer scope.
1087                        // Recursively resolve the outer flow to get the narrowed type.
1088                        // This is needed because the iterative worklist processes START
1089                        // before its outer antecedent, so the result wouldn't propagate back.
1090                        self.check_flow(reference, initial_type, outer_flow, _visited, symbol_id)
1091                    }
1092                } else {
1093                    current_type
1094                }
1095            } else {
1096                // Default: continue to antecedent
1097                if let Some(&ant) = flow.antecedent.first() {
1098                    if !in_worklist.contains(&ant) && !visited.contains(&ant) {
1099                        worklist.push_back((ant, current_type));
1100                        in_worklist.insert(ant);
1101                    }
1102                    *results.get(&ant).unwrap_or(&current_type)
1103                } else {
1104                    current_type
1105                }
1106            };
1107
1108            // For merge points (BRANCH_LABEL, LOOP_LABEL, SWITCH with fallthrough),
1109            // we union with antecedent types. For SWITCH_CLAUSE, union clause_type with fallthrough.
1110            let final_type = if is_switch_fallthrough {
1111                // Union clause_type (result_type) with fallthrough types (antecedent index 1+)
1112                let mut types = vec![result_type];
1113                for &ant in flow.antecedent.iter().skip(1) {
1114                    if let Some(&t) = results.get(&ant) {
1115                        types.push(t);
1116                    }
1117                }
1118                if types.len() == 1 {
1119                    types[0]
1120                } else {
1121                    query::union_types(self.interner, types)
1122                }
1123            } else if flow.has_any_flags(flow_flags::BRANCH_LABEL | flow_flags::LOOP_LABEL)
1124                && !flow.antecedent.is_empty()
1125            {
1126                // Union all antecedent types for branch/loop
1127                let ant_types: Vec<TypeId> = flow
1128                    .antecedent
1129                    .iter()
1130                    .filter_map(|&ant| results.get(&ant).copied())
1131                    .collect();
1132
1133                if ant_types.len() == 1 {
1134                    ant_types[0]
1135                } else if !ant_types.is_empty() {
1136                    query::union_types(self.interner, ant_types)
1137                } else {
1138                    result_type
1139                }
1140            } else {
1141                result_type
1142            };
1143
1144            results.insert(current_flow, final_type);
1145            visited.insert(current_flow);
1146
1147            // Store result in global cache for future calls
1148            // CRITICAL: Only cache if BOTH initial and final types are concrete (no type parameters).
1149            // This prevents the "Generic Result" bug where narrowing introduces type parameters.
1150            if let Some(cache) = self.flow_cache {
1151                let final_has_type_params =
1152                    query::contains_type_parameters(self.interner, final_type);
1153
1154                // Only cache if neither initial nor final types contain type parameters
1155                if !initial_has_type_params && !final_has_type_params {
1156                    let key = (current_flow, cache_symbol, initial_type);
1157                    cache.borrow_mut().insert(key, final_type);
1158                }
1159            }
1160        }
1161
1162        // Return the result for the initial flow_id
1163        results.get(&flow_id).copied().unwrap_or(initial_type)
1164    }
1165
1166    /// Helper function for switch clause handling in iterative mode.
1167    pub(crate) fn handle_switch_clause_iterative(
1168        &self,
1169        reference: NodeIndex,
1170        current_type: TypeId,
1171        flow: &FlowNode,
1172        results: &FxHashMap<FlowNodeId, TypeId>,
1173    ) -> TypeId {
1174        let clause_idx = flow.node;
1175
1176        // Check if this is an implicit default (node is the case_block itself)
1177        // This happens when a switch has no default clause - we use the case_block
1178        // as a marker to represent the implicit "no match" path
1179        let is_implicit_default = if let Some(node) = self.arena.get(clause_idx) {
1180            node.kind == syntax_kind_ext::CASE_BLOCK
1181        } else {
1182            false
1183        };
1184
1185        // For implicit default, the parent is the switch statement (not tracked in switch_clause_to_switch)
1186        let switch_idx = if is_implicit_default {
1187            // Get parent of case_block, which should be the switch statement
1188            self.arena.get_extended(clause_idx).and_then(|ext| {
1189                // The parent of the case_block is the switch statement
1190                if ext.parent.is_none() {
1191                    None
1192                } else {
1193                    Some(ext.parent)
1194                }
1195            })
1196        } else {
1197            // Normal case/default clause - use the binder's mapping
1198            self.binder.get_switch_for_clause(clause_idx)
1199        };
1200
1201        let Some(switch_idx) = switch_idx else {
1202            return current_type;
1203        };
1204        let Some(switch_node) = self.arena.get(switch_idx) else {
1205            return current_type;
1206        };
1207        let Some(switch_data) = self.arena.get_switch(switch_node) else {
1208            return current_type;
1209        };
1210
1211        let pre_switch_type = if let Some(&ant) = flow.antecedent.first() {
1212            *results.get(&ant).unwrap_or(&current_type)
1213        } else {
1214            current_type
1215        };
1216
1217        // Fast path: if this switch cannot narrow the reference at all, avoid
1218        // per-clause narrowing setup/work (narrowing context creation, expression checks).
1219        if !self.switch_can_affect_reference(switch_data.expression, reference) {
1220            return pre_switch_type;
1221        }
1222
1223        // Create narrowing context and wire up TypeEnvironment if available
1224        let env_borrow;
1225        let mut narrowing = if let Some(cache) = self.narrowing_cache {
1226            NarrowingContext::with_cache(self.interner, cache)
1227        } else {
1228            NarrowingContext::new(self.interner)
1229        };
1230
1231        if let Some(env) = &self.type_environment {
1232            env_borrow = env.borrow();
1233            narrowing = narrowing.with_resolver(&*env_borrow);
1234        }
1235
1236        // For implicit default, apply default clause narrowing (exclude all case types)
1237        if is_implicit_default {
1238            return self.narrow_by_default_switch_clause(
1239                pre_switch_type,
1240                switch_data.expression,
1241                switch_data.case_block,
1242                reference,
1243                &narrowing,
1244            );
1245        }
1246
1247        // Normal case/default clause handling
1248        let Some(clause_node) = self.arena.get(clause_idx) else {
1249            return current_type;
1250        };
1251        let Some(clause) = self.arena.get_case_clause(clause_node) else {
1252            return current_type;
1253        };
1254
1255        if clause.expression.is_none() {
1256            self.narrow_by_default_switch_clause(
1257                pre_switch_type,
1258                switch_data.expression,
1259                switch_data.case_block,
1260                reference,
1261                &narrowing,
1262            )
1263        } else if self.is_switch_true(switch_data.expression) {
1264            // For switch(true), each case expression is an independent condition.
1265            // Treat `case expr:` as `if (expr)` rather than `if (true === expr)`.
1266            self.narrow_type_by_condition(
1267                pre_switch_type,
1268                clause.expression,
1269                reference,
1270                true,
1271                FlowNodeId::NONE,
1272            )
1273        } else {
1274            self.narrow_by_switch_clause(
1275                pre_switch_type,
1276                switch_data.expression,
1277                clause.expression,
1278                reference,
1279                &narrowing,
1280            )
1281        }
1282    }
1283
1284    /// Helper function for call handling in iterative mode.
1285    pub(crate) fn handle_call_iterative(
1286        &self,
1287        reference: NodeIndex,
1288        current_type: TypeId,
1289        flow: &FlowNode,
1290        results: &FxHashMap<FlowNodeId, TypeId>,
1291    ) -> TypeId {
1292        let pre_type = if let Some(&ant) = flow.antecedent.first() {
1293            *results.get(&ant).unwrap_or(&current_type)
1294        } else {
1295            current_type
1296        };
1297
1298        let Some(node) = self.arena.get(flow.node) else {
1299            return pre_type;
1300        };
1301        if node.kind != syntax_kind_ext::CALL_EXPRESSION {
1302            return pre_type;
1303        }
1304        let Some(call) = self.arena.get_call_expr(node) else {
1305            return pre_type;
1306        };
1307
1308        let Some(node_types) = self.node_types else {
1309            return pre_type;
1310        };
1311        let Some(&callee_type) = node_types.get(&call.expression.0) else {
1312            return pre_type;
1313        };
1314        let Some(signature) = self.predicate_signature_for_type(callee_type) else {
1315            return pre_type;
1316        };
1317        if !signature.predicate.asserts {
1318            return pre_type;
1319        }
1320
1321        let Some(predicate_target) =
1322            self.predicate_target_expression(call, &signature.predicate, &signature.params)
1323        else {
1324            return pre_type;
1325        };
1326
1327        // For generic assertion functions like `assertEqual<T>(value: any, type: T): asserts value is T`,
1328        // the predicate's type_id is the unresolved type parameter T. Resolve it by matching against
1329        // the call's actual argument types.
1330        let resolved_predicate = self.resolve_generic_predicate(
1331            &signature.predicate,
1332            &signature.params,
1333            call,
1334            callee_type,
1335            node_types,
1336        );
1337
1338        if self.is_matching_reference(predicate_target, reference) {
1339            return self.apply_type_predicate_narrowing(pre_type, &resolved_predicate, true);
1340        }
1341
1342        // Discriminant narrowing: if the predicate target is a property access on the
1343        // reference (e.g., assertEqual(animal.type, 'cat') narrows animal from Cat|Dog to Cat),
1344        // extract the property path and narrow the parent object by discriminant.
1345        if let Some(predicate_type) = resolved_predicate.type_id
1346            && let Some((property_path, _is_optional, base)) =
1347                self.discriminant_property_info(predicate_target, reference)
1348            && self.is_matching_reference(base, reference)
1349        {
1350            let env_borrow;
1351            let mut narrowing = if let Some(cache) = self.narrowing_cache {
1352                NarrowingContext::with_cache(self.interner, cache)
1353            } else {
1354                NarrowingContext::new(self.interner)
1355            };
1356
1357            if let Some(env) = &self.type_environment {
1358                env_borrow = env.borrow();
1359                narrowing = narrowing.with_resolver(&*env_borrow);
1360            }
1361            return narrowing.narrow_by_discriminant(pre_type, &property_path, predicate_type);
1362        }
1363
1364        // Condition-based assertion narrowing: for `assert(condition)` where the predicate
1365        // has no type (just `asserts value`), the argument expression acts as a narrowing
1366        // condition. After the assertion, the condition is known true, so we narrow the
1367        // reference using the condition expression, just like an if-statement.
1368        // e.g., assert(typeof x === "string") narrows x to string.
1369        if resolved_predicate.type_id.is_none() {
1370            let antecedent_id = flow.antecedent.first().copied().unwrap_or(FlowNodeId::NONE);
1371            return self.narrow_type_by_condition(
1372                pre_type,
1373                predicate_target,
1374                reference,
1375                true,
1376                antecedent_id,
1377            );
1378        }
1379
1380        pre_type
1381    }
1382}