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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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}