Skip to main content

submilli_engine/typechecker/infer/
narrowing.rs

1//! Narrowing engine — pure data structures.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::{ExprId, Ident, MangledName, Type, types::LiteralF64};
6
7#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct ReferencePath {
9    pub root: BindingId,
10    pub chain: Vec<PathElem>,
11}
12
13impl ReferencePath {
14    /// A local itself, with no field or element step. Only such a path can't
15    /// change behind a guard's back, through an alias or in a call.
16    pub fn is_bare_local(&self) -> bool {
17        self.chain.is_empty() && matches!(self.root, BindingId::Local { .. })
18    }
19
20    pub fn root(root: BindingId) -> Self {
21        Self {
22            root,
23            chain: Vec::new(),
24        }
25    }
26
27    /// Whether a write to `self` may change what `other` reads. A key step
28    /// of `other` (`o[key]`) may name any field or element, so any written
29    /// step matches it. A written key matches only itself, as in
30    /// TypeScript: `values[index] = 4` leaves `values[0]` narrowed.
31    pub fn is_prefix_of(&self, other: &ReferencePath) -> bool {
32        self.root == other.root
33            && self.chain.len() <= other.chain.len()
34            && self
35                .chain
36                .iter()
37                .zip(&other.chain)
38                .all(|(written, read)| read.is_written_by(written))
39    }
40
41    pub fn render(&self) -> String {
42        let mut out = self.root.render();
43        for elem in &self.chain {
44            match elem {
45                PathElem::Field(name) => {
46                    out.push('.');
47                    out.push_str(name);
48                }
49                PathElem::Key(binding, _) => {
50                    out.push('[');
51                    out.push_str(&binding.render());
52                    out.push(']');
53                }
54                PathElem::Index(lit) => {
55                    use std::fmt::Write;
56                    match lit {
57                        LiteralValue::Number(n) => {
58                            let _ = write!(out, "[{}]", n.0);
59                        }
60                        LiteralValue::String(s) => {
61                            let _ = write!(out, "[{s:?}]");
62                        }
63                        LiteralValue::Boolean(b) => {
64                            let _ = write!(out, "[{b}]");
65                        }
66                        LiteralValue::BigInt(digits) => {
67                            let _ = write!(out, "[{digits}n]");
68                        }
69                        LiteralValue::Null => {
70                            let _ = write!(out, "[null]");
71                        }
72                        LiteralValue::Undefined => {
73                            let _ = write!(out, "[undefined]");
74                        }
75                    }
76                }
77            }
78        }
79        out
80    }
81}
82
83/// `decl_scope` on `Local` is non-optional for correctness: name-only keying
84/// lets inner scopes incorrectly inherit outer narrowings.
85///
86/// `This` needs no payload: narrow scopes are per function body and a closure
87/// boundary resets them, so within one body `this` names exactly one receiver.
88#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
89pub enum BindingId {
90    Local { name: String, decl_scope: ScopeId },
91    Global(MangledName),
92    This,
93}
94
95impl BindingId {
96    /// The name the source reads the binding by.
97    pub fn render(&self) -> String {
98        match self {
99            BindingId::Local { name, .. } => name.clone(),
100            BindingId::Global(mangled) => mangled
101                .as_str()
102                .rsplit('#')
103                .next()
104                .unwrap_or(mangled.as_str())
105                .to_string(),
106            BindingId::This => "this".to_string(),
107        }
108    }
109}
110
111#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
112pub struct ScopeId(pub u32);
113
114/// `Index` is restricted to constant literal indices so paths stay hash-comparable;
115/// `Key` indexes by a binding that holds one value for its whole life (a
116/// `const`, or a parameter or `let` never assigned), as TypeScript narrows
117/// `obj[key]`. Other index expressions are not narrowable.
118#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
119pub enum PathElem {
120    Field(String),
121    Index(LiteralValue),
122    Key(BindingId, KeyKind),
123}
124
125impl PathElem {
126    fn is_written_by(&self, written: &PathElem) -> bool {
127        matches!(self, PathElem::Key(..)) || self == written
128    }
129}
130
131/// What a [`PathElem::Key`] reads: a property, by a string key, or an element.
132#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
133pub enum KeyKind {
134    Property,
135    Element,
136}
137
138#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub enum LiteralValue {
140    Number(LiteralF64),
141    String(String),
142    Boolean(bool),
143    /// A bigint's canonical decimal digits, as [`Type::BigIntLiteral`] holds them.
144    BigInt(String),
145    /// Discriminant values only: a member may name its variant with `null` or
146    /// `undefined`, and an optional discriminant is `undefined` when absent.
147    Null,
148    Undefined,
149}
150
151impl LiteralValue {
152    /// The value an enum member literal type `E.A` holds.
153    pub fn of_enum_member(ty: &Type) -> Option<Self> {
154        Some(match ty.enum_member_value()? {
155            crate::types::EnumValue::Number(value) => LiteralValue::Number(value),
156            crate::types::EnumValue::String(value) => LiteralValue::String(value),
157        })
158    }
159}
160
161/// Bitmask over narrowing predicates, modeled on TypeScript's `TypeFacts`.
162#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
163pub struct TypeFacts(pub u16);
164
165impl TypeFacts {
166    pub const EMPTY: Self = Self(0);
167    pub const TRUTHY: Self = Self(1 << 0);
168    pub const FALSY: Self = Self(1 << 1);
169    pub const EQ_NULL: Self = Self(1 << 2);
170    pub const NE_NULL: Self = Self(1 << 3);
171    pub const IS_NUMBER: Self = Self(1 << 4);
172    pub const IS_STRING: Self = Self(1 << 5);
173    pub const IS_BOOLEAN: Self = Self(1 << 6);
174    pub const IS_OBJECT: Self = Self(1 << 7);
175    pub const IS_FUNCTION: Self = Self(1 << 8);
176    pub const IS_ARRAY: Self = Self(1 << 9);
177    pub const EQ_UNDEFINED: Self = Self(1 << 10);
178    pub const NE_UNDEFINED: Self = Self(1 << 11);
179    pub const IS_BIGINT: Self = Self(1 << 12);
180    pub const IS_UNDEFINED: Self = Self(1 << 13);
181
182    pub fn contains(self, other: Self) -> bool {
183        (self.0 & other.0) == other.0
184    }
185
186    pub fn is_empty(self) -> bool {
187        self.0 == 0
188    }
189}
190
191impl std::ops::BitOr for TypeFacts {
192    type Output = Self;
193    fn bitor(self, rhs: Self) -> Self {
194        Self(self.0 | rhs.0)
195    }
196}
197
198impl std::ops::BitOrAssign for TypeFacts {
199    fn bitor_assign(&mut self, rhs: Self) {
200        self.0 |= rhs.0;
201    }
202}
203
204impl std::ops::BitAnd for TypeFacts {
205    type Output = Self;
206    fn bitand(self, rhs: Self) -> Self {
207        Self(self.0 & rhs.0)
208    }
209}
210
211impl std::ops::Not for TypeFacts {
212    type Output = Self;
213    fn not(self) -> Self {
214        const ALL: u16 = (1 << 14) - 1;
215        Self((!self.0) & ALL)
216    }
217}
218
219/// Pushed on loop/switch entry, popped on exit; collects break and continue narrow-state snapshots.
220#[derive(Debug)]
221pub struct PendingJoinFrame {
222    pub kind: PendingJoinKind,
223    /// `narrow_scopes.len()` at the moment the construct was
224    /// entered, *before* the construct's body frame is pushed.
225    /// Snapshots taken at `break`/`continue` time use this as the
226    /// inclusive base — every frame at index >= `narrow_depth`
227    /// was pushed inside the construct and is folded into the
228    /// snapshot's narrow env (innermost wins on lookup).
229    pub narrow_depth: usize,
230    pub breaks: Vec<(NarrowEnv, BTreeSet<ReferencePath>)>,
231    pub continues: Vec<(NarrowEnv, BTreeSet<ReferencePath>)>,
232}
233
234#[derive(Copy, Clone, Debug, PartialEq, Eq)]
235pub enum PendingJoinKind {
236    /// continue targets the innermost frame of this kind.
237    Loop,
238    /// Transparent to `continue` — search past this frame when finding the continue target.
239    Switch,
240}
241
242#[derive(Clone, Debug, PartialEq, Eq)]
243pub struct NarrowedView {
244    pub narrowed_ty: Type,
245    pub facts: TypeFacts,
246    /// Literal values excluded by negative predicates (e.g., `s.kind !== "circle"`
247    /// adds `"circle"` here).
248    pub excluded_literals: BTreeSet<LiteralValue>,
249    /// Shadow Wasm local binding name (`#narrow_<N>`) codegen uses for in-region
250    /// references to this narrowed path.
251    pub binding: Ident,
252    /// Un-narrowed source expression codegen evaluates at `NarrowRegion` entry.
253    /// Minted fresh — not shared with other AST positions (would double-trigger
254    /// walker visits).
255    pub source: ExprId,
256}
257
258/// The cast is **unchecked**: codegen trusts the typechecker's narrowing decision. If the
259/// predicate didn't actually prove what the narrowing claims, the Wasm-level cast traps.
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub struct CastInfo {
262    pub from_ty: Type,
263    pub to_ty: Type,
264    pub cast_kind: CastKind,
265}
266
267/// Dispatch hint for `emit_narrowing_cast`. Variants carry **no Wasm-level
268/// indices** — those live in codegen's own registries.
269#[derive(Copy, Clone, Debug, PartialEq, Eq)]
270pub enum CastKind {
271    /// `T | null` → `T`. Codegen emits `ref.as_non_null`.
272    NonNull,
273    /// Boxed primitive → unboxed primitive value. Codegen emits an
274    /// (optional) `ref.as_non_null` (when `from_ty` is nullable), a
275    /// `ref.cast` to the wrapper subtype (`$BoxedNumber`/`$BoxedBoolean`),
276    /// and `struct.get $value`.
277    Unbox,
278    /// Ref-typed value → more-specific ref subtype. Codegen emits `ref.cast`
279    /// to the registered subtype when `to_ty`'s Wasm repr is tighter than
280    /// `from_ty`'s; emits no instruction when reps match.
281    RefSubtype,
282}
283
284/// Keep in sync with `emit_narrowing_cast` in `codegen/function_emitter/cast.rs`.
285pub fn cast_info_for(from_ty: Type, narrowed_ty: Type) -> CastInfo {
286    let cast_kind = match &narrowed_ty {
287        Type::Number | Type::NumberLiteral(_) | Type::Boolean | Type::BooleanLiteral(_) => {
288            CastKind::Unbox
289        }
290        Type::Object { .. } | Type::InterfaceRef { .. } | Type::ClassRef { .. } => {
291            CastKind::RefSubtype
292        }
293        // unknown → ref-typed concrete shape uses the same downcast.
294        Type::String
295        | Type::StringLiteral(_)
296        | Type::Array(_)
297        | Type::Tuple(_)
298        | Type::Uint8Array
299            if matches!(from_ty.peel(), Type::Unknown) =>
300        {
301            CastKind::RefSubtype
302        }
303        _ => CastKind::NonNull,
304    };
305    CastInfo {
306        from_ty,
307        to_ty: narrowed_ty,
308        cast_kind,
309    }
310}
311
312/// One field-path narrowing that survived the `if`-join and needs a
313/// fresh shadow Wasm local materialized at the join site.
314#[derive(Clone, Debug)]
315pub struct PendingPostIfMaterialization {
316    pub path: ReferencePath,
317    pub source: ExprId,
318    pub binding: Ident,
319    pub cast_info: CastInfo,
320    pub span: crate::Span,
321}
322
323/// Diagnostic-only record of why a narrowing was invalidated. Surfaced as a
324/// `help:` line so the LLM doesn't have to guess why a narrowing went away.
325#[derive(Clone, Debug)]
326pub enum InvalidationReason {
327    /// A proven predicate had no source that codegen could rebuild in its region.
328    ShapeUnrebuildable { narrowed_ty: Type },
329    /// A write reached the path (or a prefix of it) and falsified the guard:
330    /// a field or index write, a loop back edge, or a merge of an inner block's
331    /// writes. Narrowings on the path and any extension of it are dropped.
332    Write { span: crate::Span },
333    /// An identifier was reassigned. Distinguished from [`Self::Write`] only so
334    /// the diagnostic can name the statement the reader is looking at.
335    Reassignment { span: crate::Span },
336    /// Path root is in captured_mutators; the engine refused to install a NarrowedView.
337    /// closure_span: one offending closure declaration.
338    CapturedMutator { closure_span: Option<crate::Span> },
339}
340
341impl InvalidationReason {
342    pub fn invalidates(&self) -> bool {
343        !matches!(self, Self::ShapeUnrebuildable { .. })
344    }
345
346    pub fn span(&self) -> Option<crate::Span> {
347        match self {
348            Self::ShapeUnrebuildable { .. } => None,
349            Self::Write { span } => Some(*span),
350            Self::Reassignment { span } => Some(*span),
351            Self::CapturedMutator { closure_span } => *closure_span,
352        }
353    }
354}
355
356/// A union's discriminant: the property every member types with a distinct
357/// literal, and which literal names which member.
358pub type Discriminant = (String, DiscriminantTable);
359
360/// The literals that name a union's members. Each literal names one member,
361/// except `undefined`: every member whose discriminant is optional reads it
362/// when the field is absent, so it may name several.
363#[derive(Clone, Debug, Default, PartialEq)]
364pub struct DiscriminantTable {
365    entries: Vec<(LiteralValue, VariantIdx)>,
366}
367
368/// How many members `undefined` may name in a [`DiscriminantTable`].
369#[derive(Clone, Copy, PartialEq, Eq)]
370enum UndefinedNames {
371    /// An object field: every member with an optional discriminant reads it.
372    Several,
373    /// A tuple position, which is always present: like any literal, one.
374    One,
375}
376
377impl DiscriminantTable {
378    pub fn iter(&self) -> impl Iterator<Item = (&LiteralValue, &VariantIdx)> {
379        self.entries.iter().map(|(value, variant)| (value, variant))
380    }
381
382    /// The member `value` names, when it names exactly one.
383    pub fn get(&self, value: &LiteralValue) -> Option<VariantIdx> {
384        let mut variants = self.variants_for(value);
385        let variant = variants.next()?;
386        variants.next().is_none().then_some(variant)
387    }
388
389    /// Every member `value` names.
390    pub fn variants_for<'a>(
391        &'a self,
392        value: &'a LiteralValue,
393    ) -> impl Iterator<Item = VariantIdx> + 'a {
394        self.entries
395            .iter()
396            .filter(move |(entry, _)| entry == value)
397            .map(|(_, variant)| *variant)
398    }
399
400    /// Whether some literal other than `value` also names `variant`, so a
401    /// test against `value` alone can't rule it out.
402    pub fn has_other_values(&self, variant: VariantIdx, value: &LiteralValue) -> bool {
403        self.entries
404            .iter()
405            .any(|(entry, named)| *named == variant && entry != value)
406    }
407
408    /// Whether a `switch` covering `covered` handles `variant`: every value
409    /// that names it is a case.
410    pub fn is_covered(&self, variant: VariantIdx, covered: &BTreeSet<LiteralValue>) -> bool {
411        self.entries
412            .iter()
413            .filter(|(_, named)| *named == variant)
414            .all(|(value, _)| covered.contains(value))
415    }
416
417    /// The `members` a `switch` covering `covered` still leaves possible.
418    pub fn uncovered_members(
419        &self,
420        members: &[Type],
421        covered: &BTreeSet<LiteralValue>,
422    ) -> Vec<Type> {
423        members
424            .iter()
425            .enumerate()
426            .filter(|(index, _)| {
427                u32::try_from(*index)
428                    .map_or(true, |index| !self.is_covered(VariantIdx(index), covered))
429            })
430            .map(|(_, member)| member.clone())
431            .collect()
432    }
433
434    /// The values `covered` leaves out, each once, in table order.
435    pub fn uncovered(&self, covered: &BTreeSet<LiteralValue>) -> Vec<LiteralValue> {
436        let mut missing: Vec<LiteralValue> = Vec::new();
437        for (value, _) in &self.entries {
438            if !covered.contains(value) && !missing.contains(value) {
439                missing.push(value.clone());
440            }
441        }
442        missing
443    }
444
445    /// `members` split by a test against `literal`: the members it names,
446    /// and the ones still possible when the test fails. A named member stays
447    /// on both sides when another value also names it, as an optional
448    /// `kind?: "a"` does with `undefined`. `None` when `literal` names none.
449    pub fn partition(
450        &self,
451        members: &[Type],
452        literal: &LiteralValue,
453    ) -> Option<(Vec<Type>, Vec<Type>)> {
454        let named: Vec<VariantIdx> = self.variants_for(literal).collect();
455        if named.is_empty() {
456            return None;
457        }
458        let mut matched = Vec::with_capacity(named.len());
459        let mut remaining = Vec::new();
460        for (index, member) in members.iter().enumerate() {
461            let variant = VariantIdx(u32::try_from(index).ok()?);
462            if !named.contains(&variant) {
463                remaining.push(member.clone());
464                continue;
465            }
466            matched.push(member.clone());
467            if self.has_other_values(variant, literal) {
468                remaining.push(member.clone());
469            }
470        }
471        Some((matched, remaining))
472    }
473
474    /// Add `value` for `variant`, unless it already names a member: then the
475    /// key or position is no discriminant, and this returns `None`.
476    fn insert(
477        &mut self,
478        value: LiteralValue,
479        variant: VariantIdx,
480        undefined: UndefinedNames,
481    ) -> Option<()> {
482        let shareable = undefined == UndefinedNames::Several && value == LiteralValue::Undefined;
483        if !shareable && self.entries.iter().any(|(entry, _)| *entry == value) {
484            return None;
485        }
486        self.entries.push((value, variant));
487        Some(())
488    }
489}
490
491/// Variant index in a `Type::Union`'s canonical member list.
492#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
493pub struct VariantIdx(pub u32);
494
495/// Result of analyzing a `Type::Union` for a discriminant property.
496///
497/// `Some((key_property, constituent_map))` when there exists a property that:
498///
499/// - appears on every member of the union,
500/// - has a unit (literal) type on every member,
501/// - uniquely identifies each member.
502///
503/// `None` otherwise.
504///
505/// The key is returned as a bare `String` (not `Ident`) — the discriminant
506/// property name has no meaningful source span at the union level; callers
507/// compare it against `PathElem::Field(String)` from a `ReferencePath` anyway.
508pub fn union_discriminant(members: &[Type]) -> Option<Discriminant> {
509    // Inline object shapes only. A nominal member (interface, class) needs the
510    // type registry to expand, which callers that have an `Inferer` supply
511    // through [`discriminant_from_shapes`].
512    //
513    // Peel aliases so `type Shape = Circle | Rectangle` finds its discriminant.
514    let shapes = inline_member_shapes(members)?;
515    discriminant_from_shapes(&shapes)
516}
517
518/// Each member's fields, when every member is an inline object type.
519/// Nominal members need the type registry; see `union_member_shapes`.
520fn inline_member_shapes(
521    members: &[Type],
522) -> Option<Vec<BTreeMap<String, crate::types::ObjectField>>> {
523    members
524        .iter()
525        .map(|m| match m.peel() {
526            Type::Object { fields, .. } => Some(fields.clone()),
527            _ => None,
528        })
529        .collect()
530}
531
532/// [`union_discriminant`] for the field a test names, rather than the one
533/// the union would pick on its own. Inline object members only; the
534/// typechecker's `Inferer::union_discriminant_for_key` also expands nominal ones.
535pub fn inline_union_discriminant_for_key(members: &[Type], key: &str) -> Option<DiscriminantTable> {
536    let shapes = inline_member_shapes(members)?;
537    discriminant_table_for_key(&shapes, key)
538}
539
540/// [`union_discriminant`] over already-expanded member shapes — one map per
541/// union member, in member order. A key every member requires wins over one
542/// a member may omit, so an optional `code?: 1` beside `kind` doesn't hide
543/// the tag; ties go to the first key in order.
544pub fn discriminant_from_shapes(
545    shapes: &[BTreeMap<String, crate::types::ObjectField>],
546) -> Option<Discriminant> {
547    let first = shapes.first()?;
548    let may_be_omitted = |key: &String| {
549        shapes
550            .iter()
551            .any(|shape| shape.get(key).is_some_and(|f| f.optional))
552    };
553    let (required, omittable): (Vec<&String>, Vec<&String>) =
554        first.keys().partition(|key| !may_be_omitted(key));
555    required
556        .into_iter()
557        .chain(omittable)
558        .find_map(|key| Some((key.clone(), discriminant_table_for_key(shapes, key)?)))
559}
560
561/// The table `key` makes a discriminant of, when every member types it with a
562/// literal and no explicit literal names two members.
563pub fn discriminant_table_for_key(
564    shapes: &[BTreeMap<String, crate::types::ObjectField>],
565    key: &str,
566) -> Option<DiscriminantTable> {
567    if shapes.len() < 2 {
568        return None;
569    }
570    let mut table = DiscriminantTable::default();
571    for (idx, shape) in shapes.iter().enumerate() {
572        let field = shape.get(key)?;
573        let lit = unit_value(&field.ty)?;
574        // An optional `kind?: "err"` reads `undefined` when absent, so it
575        // names its variant by both values, as TypeScript narrows it.
576        let absent =
577            (field.optional && lit != LiteralValue::Undefined).then_some(LiteralValue::Undefined);
578        let variant = VariantIdx(u32::try_from(idx).ok()?);
579        for value in std::iter::once(lit).chain(absent) {
580            table.insert(value, variant, UndefinedNames::Several)?;
581        }
582    }
583    Some(table)
584}
585
586/// Tuple-union analog of [`union_discriminant`]: find a position whose element
587/// type is a literal and pairwise distinct across variants.
588/// Positions are tried only up to the smallest variant's arity.
589pub fn tuple_union_discriminant(members: &[Type]) -> Option<(usize, DiscriminantTable)> {
590    if members.len() < 2 {
591        return None;
592    }
593    let tuples: Vec<&crate::types::TupleType> = members
594        .iter()
595        .map(|m| match m.peel() {
596            Type::Tuple(elements) => Some(elements),
597            _ => None,
598        })
599        .collect::<Option<_>>()?;
600    let min_arity = tuples.iter().map(|t| t.required_len()).min().unwrap_or(0);
601    (0..min_arity).find_map(|position| {
602        let mut table = DiscriminantTable::default();
603        for (idx, tuple) in tuples.iter().enumerate() {
604            let lit = tuple.get(position).and_then(unit_value)?;
605            // Positions are required, so each one names one member, `undefined`
606            // included.
607            table.insert(
608                lit,
609                VariantIdx(u32::try_from(idx).ok()?),
610                UndefinedNames::One,
611            )?;
612        }
613        Some((position, table))
614    })
615}
616
617/// The literal a `case` label compares against: `None` for an expression
618/// label whose type is no single literal.
619pub fn switch_value_literal(value: &crate::TypedSwitchValue) -> Option<LiteralValue> {
620    Some(match value {
621        crate::TypedSwitchValue::String { value, .. } => LiteralValue::String(value.clone()),
622        crate::TypedSwitchValue::Number { value, .. } => {
623            LiteralValue::Number(crate::types::LiteralF64(*value))
624        }
625        crate::TypedSwitchValue::Boolean { value, .. } => LiteralValue::Boolean(*value),
626        crate::TypedSwitchValue::Null { .. } => LiteralValue::Null,
627        crate::TypedSwitchValue::Undefined { .. } => LiteralValue::Undefined,
628        crate::TypedSwitchValue::Expr { literal, .. } => return literal.clone(),
629        crate::TypedSwitchValue::Enum { value, .. } => match value {
630            crate::EnumVariantPayload::Number(n) => {
631                LiteralValue::Number(crate::types::LiteralF64(*n))
632            }
633            crate::EnumVariantPayload::String(s) => LiteralValue::String(s.clone()),
634        },
635    })
636}
637
638/// The literal type a compared literal value has.
639pub fn literal_type(literal: &LiteralValue) -> Type {
640    match literal {
641        LiteralValue::String(s) => Type::StringLiteral(s.clone()),
642        LiteralValue::Number(n) => Type::NumberLiteral(*n),
643        LiteralValue::Boolean(b) => Type::BooleanLiteral(*b),
644        LiteralValue::BigInt(digits) => Type::BigIntLiteral(digits.clone()),
645        LiteralValue::Null => Type::Null,
646        LiteralValue::Undefined => Type::Undefined,
647    }
648}
649
650/// Whether `ty` is, or has a member that is, a literal, `null` or
651/// `undefined`: what makes a property a discriminant in TypeScript. An enum
652/// is the union of its members' literals.
653pub fn has_unit_member(ty: &Type) -> bool {
654    match ty.peel() {
655        Type::Union(members) => members.iter().any(has_unit_member),
656        Type::Null | Type::Undefined | Type::NumberEnum { .. } | Type::StringEnum { .. } => true,
657        other => unit_literal_value(other).is_some(),
658    }
659}
660
661/// Whether `case` labels for the `covered` literals, `null` and `undefined`
662/// included, match every value of `ty`.
663pub fn is_covered_by_literals(ty: &Type, covered: &BTreeSet<LiteralValue>) -> bool {
664    match ty.peel() {
665        Type::Union(members) => members
666            .iter()
667            .all(|member| is_covered_by_literals(member, covered)),
668        // `boolean` is `true | false`.
669        Type::Boolean => [true, false]
670            .into_iter()
671            .all(|value| covered.contains(&LiteralValue::Boolean(value))),
672        other => unit_value(other).is_some_and(|value| covered.contains(&value)),
673    }
674}
675
676/// Whether `ty` is a type parameter, or a union with one.
677pub fn has_type_parameter_member(ty: &Type) -> bool {
678    match ty.peel() {
679        Type::Union(members) => members.iter().any(has_type_parameter_member),
680        other => matches!(other, Type::TypeVar(_) | Type::GenericParam { .. }),
681    }
682}
683
684pub(super) fn unit_literal_value(ty: &Type) -> Option<LiteralValue> {
685    match ty.peel() {
686        Type::StringLiteral(s) => Some(LiteralValue::String(s.clone())),
687        Type::NumberLiteral(n) => Some(LiteralValue::Number(*n)),
688        Type::BooleanLiteral(b) => Some(LiteralValue::Boolean(*b)),
689        Type::BigIntLiteral(digits) => Some(LiteralValue::BigInt(digits.clone())),
690        other => LiteralValue::of_enum_member(other),
691    }
692}
693
694/// [`unit_literal_value`], with `null` and `undefined` as values too: what a
695/// discriminant or a `case` label may name.
696pub(super) fn unit_value(ty: &Type) -> Option<LiteralValue> {
697    match ty.peel() {
698        Type::Null => Some(LiteralValue::Null),
699        Type::Undefined | Type::Void => Some(LiteralValue::Undefined),
700        other => unit_literal_value(other),
701    }
702}
703
704/// Strip `Type::Null` from a union. Returns `Type::Error` for `Type::Null`
705/// itself (empty union has no representation).
706pub fn strip_null(ty: &Type) -> Type {
707    if let Type::Refined {
708        original,
709        ty: shape,
710    } = ty.without_aliases()
711    {
712        return preserve_refinement(original, strip_null(shape));
713    }
714    match ty.peel() {
715        Type::Null => Type::Error,
716        Type::Union(members) => without_nullish_refinements(Type::union(
717            members
718                .iter()
719                .filter(|m| !matches!(m.peel(), Type::Null))
720                .map(|m| {
721                    if is_type_parameter_or_its_narrowing(m) {
722                        strip_null(m)
723                    } else {
724                        m.clone()
725                    }
726                })
727                .collect(),
728        )),
729        // `unknown` is `{} | null | undefined`, as in TypeScript.
730        Type::Unknown => Type::union(vec![Type::empty_object(), Type::Undefined]),
731        // A type parameter may hold any value: `T` minus `null` is
732        // `T & ({} | undefined)`.
733        Type::GenericParam { .. } => preserve_refinement(ty, strip_null(&Type::Unknown)),
734        // `ty` itself, not its peel: peeling drops a `readonly` wrapper.
735        _ => ty.clone(),
736    }
737}
738
739/// Remove only `undefined`, preserving strict equality's distinction from null.
740/// A `void` member stays: a `void` result may hold any value, so only a
741/// `typeof` test narrows it.
742pub fn strip_undefined(ty: &Type) -> Type {
743    if let Type::Refined {
744        original,
745        ty: shape,
746    } = ty.without_aliases()
747    {
748        return preserve_refinement(original, strip_undefined(shape));
749    }
750    match ty.peel() {
751        Type::Undefined => Type::Error,
752        Type::Union(members) => without_nullish_refinements(Type::union(
753            members
754                .iter()
755                .filter(|member| !matches!(member.peel(), Type::Undefined))
756                .map(|member| {
757                    if is_type_parameter_or_its_narrowing(member) {
758                        strip_undefined(member)
759                    } else {
760                        member.clone()
761                    }
762                })
763                .collect(),
764        )),
765        Type::Unknown => Type::union(vec![Type::empty_object(), Type::Null]),
766        Type::GenericParam { .. } => preserve_refinement(ty, strip_undefined(&Type::Unknown)),
767        _ => ty.clone(),
768    }
769}
770
771/// A union member that a nullish test narrows in place: a type parameter, or
772/// a narrowing of one such as `T & ({} | null)`, whose shape `peel` hides.
773fn is_type_parameter_or_its_narrowing(member: &Type) -> bool {
774    matches!(member.peel(), Type::GenericParam { .. }) || member.refines_type_parameter()
775}
776
777/// Remove both nullish alternatives for optional access and non-null assertions.
778pub fn strip_nullish(ty: &Type) -> Type {
779    strip_undefined(&strip_null(ty))
780}
781
782/// `in` retains optional members in both branches: declaring a property does
783/// not prove that its value is defined. A present optional property can still
784/// hold `undefined`, so its read type retains optionality.
785pub fn narrow_field_presence(
786    receiver_ty: &Type,
787    field: &str,
788    lookup: &dyn Fn(&Type, &str) -> Option<crate::ObjectField>,
789) -> (Type, Type) {
790    if let Type::Refined {
791        original,
792        ty: shape,
793    } = receiver_ty.without_aliases()
794    {
795        let (present, absent) = narrow_field_presence(shape, field, lookup);
796        return (
797            preserve_refinement(original, present),
798            preserve_refinement(original, absent),
799        );
800    }
801    match receiver_ty.peel() {
802        Type::Unknown => {
803            let fields = BTreeMap::from([(
804                field.to_string(),
805                crate::ObjectField::required(Type::Unknown),
806            )]);
807            (
808                Type::Object {
809                    index: None,
810                    fields,
811                },
812                Type::Unknown,
813            )
814        }
815        Type::Object { fields, .. } => {
816            let mut present = fields.clone();
817            present
818                .entry(field.to_string())
819                .or_insert_with(|| crate::ObjectField::required(Type::Unknown));
820            let absent = match fields.get(field) {
821                Some(f) if !f.optional => Type::Error,
822                _ => receiver_ty.clone(),
823            };
824            (
825                Type::Object {
826                    index: None,
827                    fields: present,
828                },
829                absent,
830            )
831        }
832        Type::Union(members) => {
833            let has = members
834                .iter()
835                .filter(|m| lookup(m, field).is_some())
836                .cloned()
837                .collect();
838            let lacks = members
839                .iter()
840                .filter(|m| lookup(m, field).is_none_or(|f| f.optional))
841                .cloned()
842                .collect();
843            (Type::union(has), Type::union(lacks))
844        }
845        Type::InterfaceRef { .. } | Type::ClassRef { .. } => {
846            let absent = if lookup(receiver_ty, field).is_some_and(|f| !f.optional) {
847                Type::Error
848            } else {
849                receiver_ty.clone()
850            };
851            (receiver_ty.clone(), absent)
852        }
853        _ => (Type::Error, Type::Error),
854    }
855}
856
857/// `BTreeMap`, not `HashMap`, so a walk over an env is reproducible. Under a hash map
858/// `enter_closure_narrow_boundary` would hand the same program different `#narrow_<N>`
859/// numbering and different `ExprId`s per compile, and `install_joined_narrowings`'s
860/// `joined_rebound` and `retain_emittable_views`'s `candidates` would inherit that order.
861/// Only the region nesting reaches the emitted Wasm — [`wrap_order`] pins that, and
862/// locals are unnamed there — so this is defence in depth for everything else.
863#[derive(Clone, Debug, Default)]
864pub struct NarrowEnv {
865    views: BTreeMap<ReferencePath, NarrowedView>,
866    /// Predicate drops belong to this outcome, never to the condition's parent
867    /// scope. A consumer that installs no frame simply discards these records.
868    pub dropped: BTreeMap<ReferencePath, Type>,
869}
870
871impl NarrowEnv {
872    pub fn new() -> Self {
873        Self::default()
874    }
875
876    pub fn insert(&mut self, path: ReferencePath, view: NarrowedView) -> Option<NarrowedView> {
877        self.dropped.remove(&path);
878        self.views.insert(path, view)
879    }
880
881    pub fn extend_env(&mut self, other: Self) {
882        self.dropped.extend(other.dropped);
883        self.extend(other.views);
884    }
885
886    pub fn clear(&mut self) {
887        self.views.clear();
888        self.dropped.clear();
889    }
890}
891
892impl std::ops::Deref for NarrowEnv {
893    type Target = BTreeMap<ReferencePath, NarrowedView>;
894    fn deref(&self) -> &Self::Target {
895        &self.views
896    }
897}
898
899impl std::ops::DerefMut for NarrowEnv {
900    fn deref_mut(&mut self) -> &mut Self::Target {
901        &mut self.views
902    }
903}
904
905impl IntoIterator for NarrowEnv {
906    type Item = (ReferencePath, NarrowedView);
907    type IntoIter = std::collections::btree_map::IntoIter<ReferencePath, NarrowedView>;
908    fn into_iter(self) -> Self::IntoIter {
909        self.views.into_iter()
910    }
911}
912
913impl<'a> IntoIterator for &'a NarrowEnv {
914    type Item = (&'a ReferencePath, &'a NarrowedView);
915    type IntoIter = std::collections::btree_map::Iter<'a, ReferencePath, NarrowedView>;
916    fn into_iter(self) -> Self::IntoIter {
917        self.views.iter()
918    }
919}
920
921impl Extend<(ReferencePath, NarrowedView)> for NarrowEnv {
922    fn extend<I: IntoIterator<Item = (ReferencePath, NarrowedView)>>(&mut self, iter: I) {
923        for (path, view) in iter {
924            self.insert(path, view);
925        }
926    }
927}
928
929impl FromIterator<(ReferencePath, NarrowedView)> for NarrowEnv {
930    fn from_iter<I: IntoIterator<Item = (ReferencePath, NarrowedView)>>(iter: I) -> Self {
931        let mut env = Self::new();
932        env.extend(iter);
933        env
934    }
935}
936
937/// Nesting order for the wrap-the-body-in-one-region-per-path loops: longest chain
938/// first, so every path is wrapped *inside* the prefixes its source reads. Ties break
939/// on the path itself, making this a total order — the emitted nesting must not depend
940/// on the order the caller happened to iterate its env in.
941pub fn wrap_order(a: &ReferencePath, b: &ReferencePath) -> std::cmp::Ordering {
942    b.chain.len().cmp(&a.chain.len()).then_with(|| a.cmp(b))
943}
944
945/// Compares only `narrowed_ty` per path — `binding` and `source` differ
946/// across mints even when the narrowing is unchanged.
947pub fn envs_per_path_narrowed_ty_equal(a: &NarrowEnv, b: &NarrowEnv) -> bool {
948    if a.len() != b.len() {
949        return false;
950    }
951    for (path, a_view) in a {
952        match b.get(path) {
953            Some(b_view) if a_view.narrowed_ty == b_view.narrowed_ty => continue,
954            _ => return false,
955        }
956    }
957    true
958}
959
960/// JS-truthiness classification of a single (non-union) type: which runtime
961/// test decides truthiness for values of that member.
962#[derive(Clone, Copy, Debug, PartialEq, Eq)]
963pub enum TruthinessClass {
964    AlwaysTruthy,
965    AlwaysFalsy,
966    /// Falsy iff `0`, `-0`, or `NaN`. Includes number enums (may hold `0`).
967    NumberLike,
968    /// Falsy iff `""`. Includes string enums (may hold `""`).
969    StringLike,
970    /// Falsy iff `false`.
971    BooleanLike,
972    /// Falsy iff `0n`.
973    BigIntLike,
974    /// Erased at compile time — needs the full runtime dispatch.
975    Dynamic,
976}
977
978pub fn truthiness_class(member: &Type) -> TruthinessClass {
979    use TruthinessClass::*;
980    match member.peel() {
981        Type::Null | Type::Undefined => AlwaysFalsy,
982        Type::Boolean => BooleanLike,
983        Type::BooleanLiteral(true) => AlwaysTruthy,
984        Type::BooleanLiteral(false) => AlwaysFalsy,
985        Type::Number => NumberLike,
986        Type::NumberLiteral(n) => {
987            if n.0 == 0.0 || n.0.is_nan() {
988                AlwaysFalsy
989            } else {
990                AlwaysTruthy
991            }
992        }
993        Type::String => StringLike,
994        Type::StringLiteral(s) => {
995            if s.is_empty() {
996                AlwaysFalsy
997            } else {
998                AlwaysTruthy
999            }
1000        }
1001        Type::BigInt => BigIntLike,
1002        Type::BigIntLiteral(digits) => {
1003            if digits == "0" {
1004                AlwaysFalsy
1005            } else {
1006                AlwaysTruthy
1007            }
1008        }
1009        Type::NumberEnum {
1010            member: Some(member),
1011            ..
1012        } => truthiness_class(&Type::NumberLiteral(member.value)),
1013        Type::StringEnum {
1014            member: Some(member),
1015            ..
1016        } => truthiness_class(&Type::StringLiteral(member.value.clone())),
1017        Type::NumberEnum { .. } => NumberLike,
1018        Type::StringEnum { .. } => StringLike,
1019        // `{}` admits every value but `null` and `undefined`, falsy primitives
1020        // included.
1021        empty if empty.is_empty_object() => Dynamic,
1022        Type::Object { .. }
1023        | Type::Array(_)
1024        | Type::Tuple(_)
1025        | Type::Function { .. }
1026        | Type::Uint8Array
1027        | Type::InterfaceRef { .. }
1028        | Type::ClassRef { .. } => AlwaysTruthy,
1029        _ => Dynamic,
1030    }
1031}
1032
1033/// Whether every member of `ty` is always truthy or always falsy, so a
1034/// truthiness test that rules a member out rules out every value it holds.
1035pub fn has_known_truthiness(ty: &Type) -> bool {
1036    union_members(ty).into_iter().all(|member| {
1037        matches!(
1038            truthiness_class(member),
1039            TruthinessClass::AlwaysTruthy | TruthinessClass::AlwaysFalsy
1040        )
1041    })
1042}
1043
1044/// The type of `!value` for a `value` of type `ty`, as TypeScript has it:
1045/// `false` when every value it holds is truthy, `true` when every one is
1046/// falsy, and `boolean` otherwise.
1047pub fn negation_type(ty: &Type) -> Type {
1048    let classes: Vec<TruthinessClass> = union_members(ty)
1049        .into_iter()
1050        .map(truthiness_class)
1051        .collect();
1052    if classes
1053        .iter()
1054        .all(|class| *class == TruthinessClass::AlwaysTruthy)
1055    {
1056        return Type::BooleanLiteral(false);
1057    }
1058    if classes
1059        .iter()
1060        .all(|class| *class == TruthinessClass::AlwaysFalsy)
1061    {
1062        return Type::BooleanLiteral(true);
1063    }
1064    Type::Boolean
1065}
1066
1067pub(super) fn union_members(ty: &Type) -> Vec<&Type> {
1068    match ty.peel() {
1069        Type::Union(members) => members.iter().collect(),
1070        // `ty` itself, not its peel: peeling drops a `readonly` wrapper.
1071        _ => vec![ty],
1072    }
1073}
1074
1075/// The type of `x` where `x` is known truthy: drop `null` and falsy literal
1076/// types per member. Falsy-capable base types stay whole (matching TS, which
1077/// doesn't invent a "non-empty string" type).
1078pub fn truthy_part(ty: &Type) -> Type {
1079    if let Type::Refined {
1080        original,
1081        ty: shape,
1082    } = ty.without_aliases()
1083    {
1084        return preserve_refinement(original, truthy_part(shape));
1085    }
1086    // `boolean` is exactly `true | false` (spec §1.2), so its truthy part is `true`.
1087    let kept: Vec<Type> = union_members(ty)
1088        .into_iter()
1089        .filter(|m| truthiness_class(m) != TruthinessClass::AlwaysFalsy)
1090        .map(|m| match m.without_aliases() {
1091            Type::Boolean => Type::BooleanLiteral(true),
1092            // A truthy `T` is `T & {}`, as in TypeScript.
1093            Type::GenericParam { .. } => intersect_with(m, TypeFacts::TRUTHY),
1094            _ => m.clone(),
1095        })
1096        .collect();
1097    Type::union(kept)
1098}
1099
1100/// Whether no value of `ty` is falsy.
1101pub fn is_never_falsy(ty: &Type) -> bool {
1102    union_members(ty)
1103        .into_iter()
1104        .all(|m| truthiness_class(m) == TruthinessClass::AlwaysTruthy)
1105}
1106
1107/// The type of `x` where `x` is known falsy: keep `null` and falsy literals,
1108/// collapse `string` to `""` and `boolean` to `false`, drop never-falsy
1109/// reference types. `{}` stays `{}`: it may hold `0` or `""`, though
1110/// TypeScript's `&&` drops it. `number` stays `number` — a `0` literal would
1111/// be unsound for `NaN`/`-0`.
1112pub fn falsy_part(ty: &Type) -> Type {
1113    if let Type::Refined {
1114        original,
1115        ty: shape,
1116    } = ty.without_aliases()
1117    {
1118        return preserve_refinement(original, falsy_part(shape));
1119    }
1120    let kept: Vec<Type> = union_members(ty)
1121        .into_iter()
1122        .filter_map(|m| match truthiness_class(m) {
1123            TruthinessClass::AlwaysTruthy => None,
1124            TruthinessClass::StringLike => Some(match m.peel() {
1125                Type::String => Type::StringLiteral(String::new()),
1126                _ => m.clone(),
1127            }),
1128            _ => Some(match m.without_aliases() {
1129                Type::Boolean => Type::BooleanLiteral(false),
1130                _ => m.clone(),
1131            }),
1132        })
1133        .collect();
1134    Type::union(kept)
1135}
1136
1137/// True when every falsy value `ty` can hold is `null` — i.e. a truthiness
1138/// test is exactly a null test, so the false branch may assert `EQ_NULL`.
1139pub fn falsy_values_are_only_null(ty: &Type) -> bool {
1140    union_members(ty).into_iter().all(|m| {
1141        matches!(m.peel(), Type::Null) || truthiness_class(m) == TruthinessClass::AlwaysTruthy
1142    })
1143}
1144
1145pub fn type_matches_facts(ty: &Type, facts: TypeFacts) -> bool {
1146    // Aliases are transparent for type identity — peel before matching so an
1147    // aliased primitive/union (e.g. `type NS = number | string`) classifies the
1148    // same as its inline spelling.
1149    let ty = ty.peel();
1150    // TRUTHY/FALSY subsume the null tests (truthy ⇒ non-null), so they are
1151    // checked first: `TRUTHY | NE_NULL` filters by truthiness, bare `NE_NULL`
1152    // stays a pure null test.
1153    if facts.contains(TypeFacts::TRUTHY) {
1154        return truthiness_class(ty) != TruthinessClass::AlwaysFalsy;
1155    }
1156    if facts.contains(TypeFacts::FALSY) {
1157        return truthiness_class(ty) != TruthinessClass::AlwaysTruthy;
1158    }
1159    if facts.contains(TypeFacts::NE_NULL) || facts.contains(TypeFacts::NE_UNDEFINED) {
1160        return !(facts.contains(TypeFacts::NE_NULL) && matches!(ty, Type::Null)
1161            || facts.contains(TypeFacts::NE_UNDEFINED) && matches!(ty, Type::Undefined));
1162    }
1163    // `void` is not `undefined` here: `intersect_with` narrows it on a
1164    // `typeof` test, and `subtract` never rules it out.
1165    if facts.contains(TypeFacts::IS_UNDEFINED) {
1166        return matches!(ty, Type::Undefined);
1167    }
1168    if facts.contains(TypeFacts::EQ_UNDEFINED) {
1169        return matches!(ty, Type::Undefined);
1170    }
1171    if facts.contains(TypeFacts::EQ_NULL) {
1172        return matches!(ty, Type::Null);
1173    }
1174    if facts.contains(TypeFacts::IS_BIGINT) {
1175        return matches!(ty, Type::BigInt);
1176    }
1177    if facts.contains(TypeFacts::IS_NUMBER) {
1178        // An enum member's `typeof` is its underlying primitive, as in TypeScript.
1179        return matches!(
1180            ty,
1181            Type::Number | Type::NumberLiteral(_) | Type::NumberEnum { .. }
1182        );
1183    }
1184    if facts.contains(TypeFacts::IS_STRING) {
1185        return matches!(
1186            ty,
1187            Type::String | Type::StringLiteral(_) | Type::StringEnum { .. }
1188        );
1189    }
1190    if facts.contains(TypeFacts::IS_BOOLEAN) {
1191        return matches!(ty, Type::Boolean | Type::BooleanLiteral(_));
1192    }
1193    if facts.contains(TypeFacts::IS_ARRAY) {
1194        return matches!(ty, Type::Array(_));
1195    }
1196    if facts.contains(TypeFacts::IS_FUNCTION) {
1197        return matches!(ty, Type::Function { .. });
1198    }
1199    if facts.contains(TypeFacts::IS_OBJECT) {
1200        return is_typeof_object(ty);
1201    }
1202    false
1203}
1204
1205/// Does `typeof` answer `"object"` for a value of `ty`?
1206///
1207/// Exhaustive on purpose. `"object"` is the complement of the primitive tags,
1208/// so a type constructor with no arm here is a value with *no* `typeof` at all —
1209/// every one of the five tags folds to `false`, which is unsatisfiable and
1210/// silent. Matching exhaustively makes a new [`Type`] variant fail to compile
1211/// until someone decides its tag.
1212fn is_typeof_object(ty: &Type) -> bool {
1213    match ty {
1214        // A union reaches here from the tuple-element rollup, where the
1215        // question is whether the whole element answers the tag.
1216        Type::Union(members) => members.iter().all(is_typeof_object),
1217
1218        Type::Object { .. }
1219        | Type::Array(_)
1220        | Type::Tuple(_)
1221        | Type::InterfaceRef { .. }
1222        | Type::ClassRef { .. }
1223        | Type::Uint8Array
1224        // `typeof null === "object"` — the JS quirk.
1225        | Type::Null => true,
1226
1227        Type::Undefined
1228        | Type::Number
1229        | Type::NumberLiteral(_)
1230        | Type::NumberEnum { .. }
1231        | Type::String
1232        | Type::StringLiteral(_)
1233        | Type::StringEnum { .. }
1234        | Type::Boolean
1235        | Type::BooleanLiteral(_)
1236        | Type::BigInt
1237        | Type::BigIntLiteral(_)
1238        | Type::Function { .. } => false,
1239
1240        // Not classifiable, for four different reasons: no value at all
1241        // (`Void`, `Never`), a diagnostic already reported (`Error`), erased at
1242        // runtime or bodyless (`TypeVar`, `GenericParam`, `Unknown`, and a
1243        // recursive alias's back-edge `AliasRef`). `fold_typeof_tag` bails on
1244        // the erased set before it ever asks, so `false` here only ever means
1245        // "narrows nothing", never "answers no tag".
1246        Type::Void
1247        | Type::Never
1248        | Type::Error
1249        | Type::Unknown
1250        | Type::TypeVar(_)
1251        | Type::GenericParam { .. }
1252        | Type::AliasRef { .. } => false,
1253
1254        // Peeled by the caller.
1255        Type::Alias { .. } | Type::Refined { .. } | Type::Readonly(_) => false,
1256    }
1257}
1258
1259/// Keep only the union members of `ty` that match `facts`. For a
1260/// non-union `ty`, returns `ty` if matched or [`Type::Error`] if not.
1261pub fn intersect_with(ty: &Type, facts: TypeFacts) -> Type {
1262    if let Type::Refined {
1263        original,
1264        ty: shape,
1265    } = ty.without_aliases()
1266    {
1267        return preserve_refinement(original, intersect_with(shape, facts));
1268    }
1269    let peeled = ty.peel();
1270    if let Some(asserted) = asserted_type_for_facts(facts) {
1271        match peeled {
1272            Type::Unknown => return asserted,
1273            // `{}` holds every primitive, so a `typeof` test pins it as it does
1274            // `unknown`. It holds no `null` or `undefined`, so those tests fall
1275            // through and rule it out.
1276            empty
1277                if empty.is_empty_object() && !matches!(asserted, Type::Null | Type::Undefined) =>
1278            {
1279                return asserted;
1280            }
1281            Type::GenericParam { .. } => {
1282                return Type::Refined {
1283                    original: Box::new(ty.clone()),
1284                    ty: Box::new(asserted),
1285                };
1286            }
1287            _ => {}
1288        }
1289    }
1290    if matches!(peeled, Type::Unknown) && tests_only_presence(facts) {
1291        return recombine_unknown(intersect_with(&unknown_as_union(), facts));
1292    }
1293    if matches!(peeled, Type::GenericParam { .. }) && tests_only_presence(facts) {
1294        return preserve_refinement(ty, intersect_with(&Type::Unknown, facts));
1295    }
1296    match peeled {
1297        Type::Union(members) => without_nullish_refinements(Type::union(
1298            members
1299                .iter()
1300                .map(|m| intersect_with(m, facts))
1301                .filter(|ty| !matches!(ty, Type::Error))
1302                .collect(),
1303        )),
1304        // An explicit typeof test supplies the runtime fact that a void
1305        // value's static type alone cannot establish.
1306        Type::Void if facts.contains(TypeFacts::IS_UNDEFINED) => Type::Undefined,
1307        // `boolean` is `true | false`, and truthiness splits it.
1308        Type::Boolean if facts == TypeFacts::TRUTHY => Type::BooleanLiteral(true),
1309        Type::Boolean if facts == TypeFacts::FALSY => Type::BooleanLiteral(false),
1310        _ if type_matches_facts(peeled, facts) => ty.clone(),
1311        _ if matches!(peeled, Type::Unknown) => Type::Unknown,
1312        _ => Type::Error,
1313    }
1314}
1315
1316pub fn subtract(ty: &Type, facts: TypeFacts) -> Type {
1317    if let Type::Refined {
1318        original,
1319        ty: shape,
1320    } = ty.without_aliases()
1321    {
1322        return preserve_refinement(original, subtract(shape, facts));
1323    }
1324    let peeled = ty.peel();
1325    // `unknown` minus `null` or `undefined` is the rest of `{} | null |
1326    // undefined`, as in TypeScript; minus any other type it stays `unknown`.
1327    if matches!(peeled, Type::Unknown) {
1328        if tests_only_nullishness(facts) {
1329            return recombine_unknown(subtract(&unknown_as_union(), facts));
1330        }
1331        return Type::Unknown;
1332    }
1333    if matches!(peeled, Type::GenericParam { .. }) {
1334        return preserve_refinement(ty, subtract(&Type::Unknown, facts));
1335    }
1336    // A `void` result may hold any value, so no test rules it out: `typeof
1337    // v !== "undefined"` keeps it, though `typeof v === "undefined"` may
1338    // narrow it to `undefined`.
1339    let ruled_out =
1340        |member: &Type| !matches!(member.peel(), Type::Void) && type_matches_facts(member, facts);
1341    match peeled {
1342        Type::Union(members) => {
1343            let kept: Vec<Type> = members.iter().filter(|m| !ruled_out(m)).cloned().collect();
1344            Type::union(kept)
1345        }
1346        _ if !ruled_out(peeled) => ty.clone(),
1347        _ => Type::Error,
1348    }
1349}
1350
1351pub(super) fn with_source_refinement(source: &Type, shape: Type) -> Type {
1352    match source.without_aliases() {
1353        Type::Refined { original, .. } => preserve_refinement(original, shape),
1354        _ => shape,
1355    }
1356}
1357
1358pub(super) fn preserve_refinement(original: &Type, shape: Type) -> Type {
1359    if matches!(shape, Type::Error | Type::Never) {
1360        return shape;
1361    }
1362    // A shape that proves nothing leaves the type parameter as it was.
1363    if matches!(shape, Type::Unknown) {
1364        return original.clone();
1365    }
1366    Type::Refined {
1367        original: Box::new(original.clone()),
1368        ty: Box::new(shape),
1369    }
1370}
1371
1372/// Strip covered literal values from `ty`. When the residual is `Type::Never`,
1373/// all discriminant values were covered by `case` labels.
1374///
1375/// - **Union**: filter out covered literal members, keeping the rest (`number`
1376///   in `number | "a"`); collapses to `Never` when all drop.
1377/// - **Single literal**: `Never` if covered, unchanged otherwise.
1378/// - **Anything else**: returned unchanged.
1379pub fn subtract_literals(ty: &Type, covered: &BTreeSet<LiteralValue>) -> Type {
1380    if let Type::Refined {
1381        original,
1382        ty: shape,
1383    } = ty.without_aliases()
1384    {
1385        return preserve_refinement(original, subtract_literals(shape, covered));
1386    }
1387    match ty.peel() {
1388        Type::Union(members) => {
1389            let kept: Vec<Type> = members
1390                .iter()
1391                .map(|m| subtract_literals(m, covered))
1392                .collect();
1393            Type::union(kept)
1394        }
1395        // `boolean` is `true | false`, so `case true:` leaves `false`.
1396        Type::Boolean => {
1397            let remaining: Vec<Type> = [false, true]
1398                .into_iter()
1399                .filter(|b| !covered.contains(&LiteralValue::Boolean(*b)))
1400                .map(Type::BooleanLiteral)
1401                .collect();
1402            Type::union(remaining)
1403        }
1404        single @ (Type::StringLiteral(_)
1405        | Type::NumberLiteral(_)
1406        | Type::BooleanLiteral(_)
1407        | Type::BigIntLiteral(_)
1408        | Type::Null
1409        | Type::Undefined) => match unit_value(single) {
1410            Some(lit) if covered.contains(&lit) => Type::Never,
1411            _ => ty.clone(),
1412        },
1413        _ => ty.clone(),
1414    }
1415}
1416
1417/// Whether `facts` test only whether a value is present: truthiness, `null`
1418/// and `undefined`. Against those, `unknown` narrows as `{} | null |
1419/// undefined`, which is what TypeScript takes it to mean.
1420fn tests_only_presence(facts: TypeFacts) -> bool {
1421    let presence = TypeFacts::TRUTHY
1422        | TypeFacts::FALSY
1423        | TypeFacts::EQ_NULL
1424        | TypeFacts::NE_NULL
1425        | TypeFacts::EQ_UNDEFINED
1426        | TypeFacts::NE_UNDEFINED
1427        | TypeFacts::IS_UNDEFINED;
1428    facts != TypeFacts::EMPTY && presence.contains(facts)
1429}
1430
1431/// Whether `facts` test only for `null` or `undefined`, not truthiness.
1432fn tests_only_nullishness(facts: TypeFacts) -> bool {
1433    tests_only_presence(facts)
1434        && !facts.contains(TypeFacts::TRUTHY)
1435        && !facts.contains(TypeFacts::FALSY)
1436}
1437
1438/// `union` with each refined type parameter's own `null` or `undefined`
1439/// dropped where the union holds that value already, as TypeScript reduces
1440/// `(T & ({} | null)) | null` to `(T & {}) | null`.
1441fn without_nullish_refinements(union: Type) -> Type {
1442    let Type::Union(members) = &union else {
1443        return union;
1444    };
1445    let holds = |value: &Type| members.iter().any(|member| member.peel() == value);
1446    let (holds_null, holds_undefined) = (holds(&Type::Null), holds(&Type::Undefined));
1447    if !holds_null && !holds_undefined {
1448        return union;
1449    }
1450    Type::union(
1451        members
1452            .iter()
1453            .filter_map(|member| match member {
1454                Type::Refined { original, ty } => {
1455                    let mut shape = (**ty).clone();
1456                    if holds_null {
1457                        shape = strip_null(&shape);
1458                    }
1459                    if holds_undefined {
1460                        shape = strip_undefined(&shape);
1461                    }
1462                    // `T & null` beside `null` adds nothing.
1463                    (!is_ruled_out(&shape)).then(|| preserve_refinement(original, shape))
1464                }
1465                other => Some(other.clone()),
1466            })
1467            .collect(),
1468    )
1469}
1470
1471/// `unknown` as the values it holds: `{} | null | undefined`.
1472fn unknown_as_union() -> Type {
1473    Type::Union(vec![Type::empty_object(), Type::Null, Type::Undefined])
1474}
1475
1476/// What is left of `unknown` after a narrowing: `unknown` again when the
1477/// narrowing ruled nothing out, as TypeScript's `recombineUnknownType` does.
1478fn recombine_unknown(ty: Type) -> Type {
1479    if ty.holds_every_value() {
1480        return Type::Unknown;
1481    }
1482    ty
1483}
1484
1485/// Map a [`TypeFacts`] mask to the concrete `Type` it asserts when narrowing
1486/// `unknown`. Returns `None` when facts don't pin a single type (TRUTHY,
1487/// IS_OBJECT span multiple types).
1488fn asserted_type_for_facts(facts: TypeFacts) -> Option<Type> {
1489    if facts.contains(TypeFacts::IS_BIGINT) {
1490        return Some(Type::BigInt);
1491    }
1492    if facts.contains(TypeFacts::IS_NUMBER) {
1493        return Some(Type::Number);
1494    }
1495    if facts.contains(TypeFacts::IS_STRING) {
1496        return Some(Type::String);
1497    }
1498    if facts.contains(TypeFacts::IS_BOOLEAN) {
1499        return Some(Type::Boolean);
1500    }
1501    if facts.contains(TypeFacts::IS_ARRAY) {
1502        return Some(Type::Array(Box::new(Type::Unknown)));
1503    }
1504    if facts.contains(TypeFacts::EQ_NULL) {
1505        return Some(Type::Null);
1506    }
1507    if facts.contains(TypeFacts::EQ_UNDEFINED) || facts.contains(TypeFacts::IS_UNDEFINED) {
1508        return Some(Type::Undefined);
1509    }
1510    // IS_OBJECT admits arrays, objects, and null (the JS typeof-null quirk);
1511    // no single concrete type, so caller keeps `Unknown`.
1512    None
1513}
1514
1515/// Join two branch post-environments at a control-flow merge point:
1516///
1517/// - Paths narrowed in **both** branches: joined type is `Type::union` of the two.
1518/// - Paths narrowed in only one branch: dropped.
1519/// - Paths a branch *extended* by assignment (`o` assigned, `o.f` narrowed):
1520///   dropped — the write can invalidate everything below it.
1521///
1522/// An assignment to the narrowed path *itself* does not drop it: each branch's
1523/// env already carries what that branch's assignment left behind
1524/// (`install_assignment_narrowing` records the assigned type), so the union of
1525/// the two is exactly the post-join type. That is what makes
1526/// `if (s === null) { s = "x"; }` leave `s: string` behind.
1527///
1528/// Assumes both branches contributed; caller handles the unreachable-branch case.
1529pub fn union_envs(
1530    a_narrowings: NarrowEnv,
1531    a_assigned: BTreeSet<ReferencePath>,
1532    b_narrowings: NarrowEnv,
1533    b_assigned: BTreeSet<ReferencePath>,
1534) -> (NarrowEnv, BTreeSet<ReferencePath>) {
1535    let mut joined_narrowings = NarrowEnv::new();
1536    for (path, a_view) in &a_narrowings {
1537        if let Some(b_view) = b_narrowings.get(path) {
1538            let Some(joined_ty) = join_narrowed_types(&a_view.narrowed_ty, &b_view.narrowed_ty)
1539            else {
1540                continue;
1541            };
1542            // A ruled-out view has no shadow, so the join reads through the other.
1543            let a_view = if is_ruled_out(&a_view.narrowed_ty) {
1544                b_view
1545            } else {
1546                a_view
1547            };
1548            joined_narrowings.insert(
1549                path.clone(),
1550                NarrowedView {
1551                    narrowed_ty: joined_ty,
1552                    // Facts are per-branch; the join can't preserve
1553                    // them precisely. The downstream slim-slice
1554                    // sites only consume `narrowed_ty`, so EMPTY is
1555                    // sound.
1556                    facts: TypeFacts::EMPTY,
1557                    excluded_literals: BTreeSet::new(),
1558                    // Reuse `a`'s binding + source. The join
1559                    // typically gets dropped by the assigned-paths
1560                    // filter below; if it survives, the binding /
1561                    // source remain valid since both branches saw
1562                    // the same path.
1563                    binding: a_view.binding.clone(),
1564                    source: a_view.source,
1565                },
1566            );
1567        }
1568    }
1569
1570    for (path, ty) in a_narrowings.dropped.iter().chain(&b_narrowings.dropped) {
1571        let left_ty = a_narrowings
1572            .dropped
1573            .get(path)
1574            .or_else(|| a_narrowings.get(path).map(|view| &view.narrowed_ty));
1575        let right_ty = b_narrowings
1576            .dropped
1577            .get(path)
1578            .or_else(|| b_narrowings.get(path).map(|view| &view.narrowed_ty));
1579        if left_ty == Some(ty) && right_ty == Some(ty) {
1580            joined_narrowings.dropped.insert(path.clone(), ty.clone());
1581        }
1582    }
1583
1584    let mut joined_assigned = a_assigned;
1585    joined_assigned.extend(b_assigned);
1586
1587    joined_narrowings.dropped.retain(|key, _| {
1588        !joined_assigned
1589            .iter()
1590            .any(|assigned| assigned.is_prefix_of(key))
1591    });
1592    joined_narrowings.retain(|key, _| {
1593        !joined_assigned
1594            .iter()
1595            .any(|assigned| assigned != key && assigned.is_prefix_of(key))
1596    });
1597
1598    (joined_narrowings, joined_assigned)
1599}
1600
1601/// The tuple position a number names, if it names one.
1602pub(super) fn tuple_position(index: f64) -> Option<usize> {
1603    (index >= 0.0 && index.fract() == 0.0 && index <= u32::MAX as f64).then_some(index as usize)
1604}
1605
1606/// The type of a view whose guard ruled out every value. Narrowing yields
1607/// `Error` when nothing is left; a view keeps it, rather than `never`, so
1608/// codegen gives the path no shadow local, and a read of the path turns it
1609/// into `never` where `Inferer::rules_out_to_never` allows.
1610pub const RULED_OUT: Type = Type::Error;
1611
1612/// Whether a narrowed type is [`RULED_OUT`].
1613pub fn is_ruled_out(ty: &Type) -> bool {
1614    matches!(ty, Type::Error)
1615}
1616
1617/// The join of two narrowings of one path, or `None` when it holds every
1618/// value: then it narrows nothing, and reads see the declared type again,
1619/// `unknown` or a written `{} | null | undefined`.
1620pub(super) fn join_narrowed_types(left: &Type, right: &Type) -> Option<Type> {
1621    let joined = join_flow_types(left, right);
1622    (!joined.holds_every_value()).then_some(joined)
1623}
1624
1625/// Flow joins collapse a literal already covered by a broad primitive. Keep
1626/// authored unions unchanged: their overlap is meaningful to JSON diagnostics.
1627fn join_flow_types(left: &Type, right: &Type) -> Type {
1628    // A ruled-out side holds no value, so it adds nothing to the other.
1629    if is_ruled_out(left) {
1630        return right.clone();
1631    }
1632    if is_ruled_out(right) {
1633        return left.clone();
1634    }
1635    let joined = Type::union(vec![left.clone(), right.clone()]);
1636    let Type::Union(members) = joined else {
1637        return joined;
1638    };
1639    let mut members = crate::types::without_members_of_empty_object(members);
1640    let has_number = members
1641        .iter()
1642        .any(|ty| matches!(ty.without_aliases(), Type::Number));
1643    let has_string = members
1644        .iter()
1645        .any(|ty| matches!(ty.without_aliases(), Type::String));
1646    members.retain(|ty| match ty.without_aliases() {
1647        Type::NumberLiteral(_) => !has_number,
1648        Type::StringLiteral(_) => !has_string,
1649        _ => true,
1650    });
1651    Type::union(members)
1652}
1653
1654/// Recurses into `Type::Union` members only — composite slots have fixed runtime
1655/// classification regardless of generic contents, so their type arguments are
1656/// not walked either (`Box<T> = number` classifies as a number whatever `T` is).
1657///
1658/// Peeled: an alias of a type parameter is erased just as the parameter is, and
1659/// the caller uses this as the escape hatch that keeps a `typeof` guard on an
1660/// erased operand from folding to a constant.
1661pub fn has_erased_member(ty: &Type) -> bool {
1662    match ty.peel() {
1663        Type::TypeVar(_) | Type::GenericParam { .. } => true,
1664        Type::Union(members) => members.iter().any(has_erased_member),
1665        _ => false,
1666    }
1667}
1668
1669/// Every type is condition-compatible under JS truthiness except `unknown`
1670/// (requires explicit narrowing first, spec §2.11) and the value-less bare
1671/// `void`. A union with another value-bearing alternative is legal; its void
1672/// member remains dynamic. `never` counts, as in TypeScript: either no value
1673/// reaches the test (a call that throws, a local after an exhausted `else if`
1674/// chain), or the value is one a `never[]` an alias filled holds, which tests
1675/// as it would anywhere. `Error` is accepted to silence cascades.
1676pub fn condition_compatible(ty: &Type) -> bool {
1677    !matches!(ty.peel(), Type::Unknown | Type::Void)
1678}
1679
1680pub fn facts_for_target_type(target: &Type) -> TypeFacts {
1681    match target.peel() {
1682        Type::Number | Type::NumberLiteral(_) => TypeFacts::IS_NUMBER,
1683        Type::String | Type::StringLiteral(_) => TypeFacts::IS_STRING,
1684        Type::Boolean | Type::BooleanLiteral(_) => TypeFacts::IS_BOOLEAN,
1685        Type::Null => TypeFacts::EQ_NULL,
1686        Type::Undefined => TypeFacts::EQ_UNDEFINED,
1687        Type::BigInt => TypeFacts::IS_BIGINT,
1688        Type::Array(_) => TypeFacts::IS_ARRAY,
1689        _ => TypeFacts::EMPTY,
1690    }
1691}
1692
1693#[cfg(test)]
1694mod analyzer_tests {
1695    use super::*;
1696
1697    #[test]
1698    fn typeof_undefined_splits_void_without_assuming_its_truthiness() {
1699        let ty = Type::union(vec![Type::Boolean, Type::Void]);
1700        assert_eq!(
1701            intersect_with(&ty, TypeFacts::IS_UNDEFINED),
1702            Type::Undefined
1703        );
1704        assert_eq!(subtract(&ty, TypeFacts::IS_UNDEFINED), ty);
1705        assert_eq!(truthiness_class(&Type::Void), TruthinessClass::Dynamic);
1706        assert!(!type_matches_facts(&Type::Void, TypeFacts::EQ_UNDEFINED));
1707    }
1708
1709    #[test]
1710    fn undefined_facts_are_distinct_from_null() {
1711        let ty = Type::union(vec![Type::String, Type::Null, Type::Undefined]);
1712        assert_eq!(
1713            strip_null(&ty),
1714            Type::union(vec![Type::String, Type::Undefined])
1715        );
1716        assert_eq!(
1717            strip_undefined(&ty),
1718            Type::union(vec![Type::String, Type::Null])
1719        );
1720        assert_eq!(strip_nullish(&ty), Type::String);
1721        assert_eq!(
1722            intersect_with(&ty, TypeFacts::EQ_UNDEFINED),
1723            Type::Undefined
1724        );
1725        assert_eq!(
1726            intersect_with(&Type::Unknown, TypeFacts::EQ_UNDEFINED),
1727            Type::Undefined
1728        );
1729        assert_eq!(
1730            intersect_with(&ty, TypeFacts::NE_NULL | TypeFacts::NE_UNDEFINED),
1731            Type::String
1732        );
1733        assert_eq!(truthy_part(&ty), Type::String);
1734        assert_eq!(
1735            truthiness_class(&Type::Undefined),
1736            TruthinessClass::AlwaysFalsy
1737        );
1738        assert!(!type_matches_facts(&Type::Undefined, TypeFacts::IS_OBJECT));
1739    }
1740
1741    #[test]
1742    fn presence_keeps_optional_read_undefined() {
1743        let ty = Type::Object {
1744            fields: BTreeMap::from([("value".into(), crate::ObjectField::optional(Type::Number))]),
1745            index: None,
1746        };
1747        let (present, absent) = narrow_field_presence(&ty, "value", &|_, _| None);
1748        assert_eq!(present, ty);
1749        assert_eq!(absent, ty);
1750    }
1751
1752    #[test]
1753    fn refined_literal_residual_retains_generic_identity() {
1754        let original = Type::GenericParam {
1755            id: 1,
1756            name: "T".into(),
1757        };
1758        let shape = Type::union(vec![
1759            Type::StringLiteral("a".into()),
1760            Type::StringLiteral("b".into()),
1761        ]);
1762        let refined = preserve_refinement(&original, shape);
1763        let covered = BTreeSet::from([LiteralValue::String("a".into())]);
1764        assert_eq!(
1765            subtract_literals(&refined, &covered),
1766            preserve_refinement(&original, Type::StringLiteral("b".into()))
1767        );
1768    }
1769
1770    #[test]
1771    fn strip_null_from_nullable_string() {
1772        let ty = Type::union(vec![Type::String, Type::Null]);
1773        assert_eq!(strip_null(&ty), Type::String);
1774    }
1775
1776    #[test]
1777    fn strip_null_from_non_nullable_is_identity() {
1778        assert_eq!(strip_null(&Type::String), Type::String);
1779        assert_eq!(strip_null(&Type::Number), Type::Number);
1780    }
1781
1782    #[test]
1783    fn strip_null_from_null_is_error() {
1784        assert_eq!(strip_null(&Type::Null), Type::Error);
1785    }
1786
1787    #[test]
1788    fn strip_null_from_multi_member_union() {
1789        let ty = Type::union(vec![Type::String, Type::Number, Type::Null]);
1790        let stripped = strip_null(&ty);
1791        // Type::union canonicalizes; should be String | Number.
1792        match stripped {
1793            Type::Union(members) => assert_eq!(members.len(), 2),
1794            _ => panic!("expected Union, got {stripped:?}"),
1795        }
1796    }
1797
1798    #[test]
1799    fn intersect_with_is_number_filters_union() {
1800        let ty = Type::union(vec![Type::Number, Type::String, Type::Boolean]);
1801        let kept = intersect_with(&ty, TypeFacts::IS_NUMBER);
1802        assert_eq!(kept, Type::Number);
1803    }
1804
1805    #[test]
1806    fn subtract_is_number_keeps_others() {
1807        let ty = Type::union(vec![Type::Number, Type::String, Type::Boolean]);
1808        let kept = subtract(&ty, TypeFacts::IS_NUMBER);
1809        match kept {
1810            Type::Union(members) => {
1811                assert_eq!(members.len(), 2);
1812                assert!(members.contains(&Type::String));
1813                assert!(members.contains(&Type::Boolean));
1814            }
1815            _ => panic!("expected Union, got {kept:?}"),
1816        }
1817    }
1818
1819    #[test]
1820    fn intersect_with_truthy_strips_null() {
1821        let ty = Type::union(vec![Type::String, Type::Null]);
1822        let kept = intersect_with(&ty, TypeFacts::TRUTHY);
1823        assert_eq!(kept, Type::String);
1824    }
1825
1826    #[test]
1827    fn subtract_truthy_keeps_only_null() {
1828        let ty = Type::union(vec![Type::String, Type::Null]);
1829        let kept = subtract(&ty, TypeFacts::TRUTHY);
1830        assert_eq!(kept, Type::Null);
1831    }
1832
1833    #[test]
1834    fn intersect_with_no_match_yields_error() {
1835        let kept = intersect_with(&Type::String, TypeFacts::IS_NUMBER);
1836        assert_eq!(kept, Type::Error);
1837    }
1838
1839    #[test]
1840    fn facts_for_target_type_covers_primitives() {
1841        assert_eq!(facts_for_target_type(&Type::Number), TypeFacts::IS_NUMBER);
1842        assert_eq!(facts_for_target_type(&Type::String), TypeFacts::IS_STRING);
1843        assert_eq!(facts_for_target_type(&Type::Boolean), TypeFacts::IS_BOOLEAN);
1844        assert_eq!(facts_for_target_type(&Type::Null), TypeFacts::EQ_NULL);
1845    }
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850    use super::*;
1851    use crate::Span;
1852
1853    #[test]
1854    fn reference_path_equality_is_structural() {
1855        let a = ReferencePath::root(BindingId::Local {
1856            name: "x".to_string(),
1857            decl_scope: ScopeId(1),
1858        });
1859        let b = ReferencePath::root(BindingId::Local {
1860            name: "x".to_string(),
1861            decl_scope: ScopeId(1),
1862        });
1863        assert_eq!(a, b);
1864    }
1865
1866    #[test]
1867    fn reference_path_distinguishes_scopes() {
1868        let a = ReferencePath::root(BindingId::Local {
1869            name: "x".to_string(),
1870            decl_scope: ScopeId(1),
1871        });
1872        let b = ReferencePath::root(BindingId::Local {
1873            name: "x".to_string(),
1874            decl_scope: ScopeId(2),
1875        });
1876        assert_ne!(a, b);
1877    }
1878
1879    #[test]
1880    fn reference_path_distinguishes_chain() {
1881        let a = ReferencePath {
1882            root: BindingId::Local {
1883                name: "x".to_string(),
1884                decl_scope: ScopeId(1),
1885            },
1886            chain: vec![PathElem::Field("foo".to_string())],
1887        };
1888        let b = ReferencePath::root(BindingId::Local {
1889            name: "x".to_string(),
1890            decl_scope: ScopeId(1),
1891        });
1892        assert_ne!(a, b);
1893    }
1894
1895    #[test]
1896    fn type_facts_or_combines() {
1897        let f = TypeFacts::TRUTHY | TypeFacts::NE_NULL;
1898        assert!(f.contains(TypeFacts::TRUTHY));
1899        assert!(f.contains(TypeFacts::NE_NULL));
1900        assert!(!f.contains(TypeFacts::EQ_NULL));
1901    }
1902
1903    #[test]
1904    fn type_facts_not_complements() {
1905        let f = TypeFacts::EQ_NULL;
1906        let flipped = !f;
1907        assert!(flipped.contains(TypeFacts::NE_NULL));
1908        assert!(!flipped.contains(TypeFacts::EQ_NULL));
1909    }
1910
1911    #[test]
1912    fn binding_id_global_uses_mangled_name() {
1913        let g = BindingId::Global(crate::mangle::package_symbol("main", "x"));
1914        if let BindingId::Global(name) = &g {
1915            assert_eq!(name.as_str(), "main#x");
1916        } else {
1917            panic!("expected Global");
1918        }
1919    }
1920
1921    #[test]
1922    fn narrowed_view_construction() {
1923        let view = NarrowedView {
1924            narrowed_ty: Type::String,
1925            facts: TypeFacts::NE_NULL,
1926            excluded_literals: BTreeSet::new(),
1927            binding: Ident {
1928                name: "#narrow_0".to_string(),
1929                span: Span::new(crate::FileId(0), 0, 1).unwrap(),
1930            },
1931            source: crate::ExprId(0),
1932        };
1933        assert_eq!(view.narrowed_ty, Type::String);
1934        assert!(view.facts.contains(TypeFacts::NE_NULL));
1935    }
1936
1937    #[test]
1938    fn cast_info_holds_kind_and_types() {
1939        let info = CastInfo {
1940            from_ty: Type::Union(vec![Type::String, Type::Null]),
1941            to_ty: Type::String,
1942            cast_kind: CastKind::NonNull,
1943        };
1944        assert!(matches!(info.cast_kind, CastKind::NonNull));
1945    }
1946
1947    #[test]
1948    fn union_discriminant_stub_returns_none() {
1949        assert!(union_discriminant(&[Type::String, Type::Null]).is_none());
1950    }
1951
1952    #[test]
1953    fn intersect_unknown_with_is_string() {
1954        assert_eq!(
1955            intersect_with(&Type::Unknown, TypeFacts::IS_STRING),
1956            Type::String
1957        );
1958    }
1959
1960    #[test]
1961    fn intersect_unknown_with_is_number() {
1962        assert_eq!(
1963            intersect_with(&Type::Unknown, TypeFacts::IS_NUMBER),
1964            Type::Number
1965        );
1966    }
1967
1968    #[test]
1969    fn intersect_unknown_with_is_boolean() {
1970        assert_eq!(
1971            intersect_with(&Type::Unknown, TypeFacts::IS_BOOLEAN),
1972            Type::Boolean
1973        );
1974    }
1975
1976    #[test]
1977    fn intersect_unknown_with_is_array_carries_unknown_element() {
1978        // `Array.isArray<T>(unknown)` narrows to `Array<unknown>`
1979        // because the element type isn't statically determined.
1980        assert_eq!(
1981            intersect_with(&Type::Unknown, TypeFacts::IS_ARRAY),
1982            Type::Array(Box::new(Type::Unknown)),
1983        );
1984    }
1985
1986    #[test]
1987    fn intersect_unknown_with_eq_null() {
1988        assert_eq!(
1989            intersect_with(&Type::Unknown, TypeFacts::EQ_NULL),
1990            Type::Null
1991        );
1992    }
1993
1994    #[test]
1995    fn intersect_unknown_with_truthy_is_empty_object() {
1996        // A truthy `unknown` is neither `null` nor `undefined`: `{}`.
1997        assert_eq!(
1998            intersect_with(&Type::Unknown, TypeFacts::TRUTHY),
1999            Type::empty_object()
2000        );
2001    }
2002
2003    #[test]
2004    fn subtract_unknown_removes_only_nullish_values() {
2005        // `unknown` minus a type other than `null` or `undefined` stays
2006        // `unknown`; minus those, it is the rest of `{} | null | undefined`.
2007        assert_eq!(
2008            subtract(&Type::Unknown, TypeFacts::IS_STRING),
2009            Type::Unknown
2010        );
2011        assert_eq!(
2012            subtract(&Type::Unknown, TypeFacts::EQ_NULL),
2013            Type::union(vec![Type::empty_object(), Type::Undefined])
2014        );
2015        assert_eq!(subtract(&Type::Unknown, TypeFacts::TRUTHY), Type::Unknown);
2016    }
2017
2018    #[test]
2019    fn strip_null_from_unknown_leaves_empty_object_or_undefined() {
2020        assert_eq!(
2021            strip_null(&Type::Unknown),
2022            Type::union(vec![Type::empty_object(), Type::Undefined])
2023        );
2024        assert_eq!(strip_nullish(&Type::Unknown), Type::empty_object());
2025    }
2026
2027    #[test]
2028    fn condition_compatible_rejects_unknown() {
2029        assert!(!condition_compatible(&Type::Unknown));
2030    }
2031
2032    #[test]
2033    fn condition_compatible_accepts_js_truthiness_types() {
2034        for ty in [
2035            Type::Boolean,
2036            Type::String,
2037            Type::Number,
2038            Type::BigInt,
2039            Type::Null,
2040            Type::Array(Box::new(Type::Number)),
2041            Type::union(vec![Type::String, Type::Null]),
2042            Type::union(vec![Type::Number, Type::Void]),
2043        ] {
2044            assert!(condition_compatible(&ty), "{ty:?}");
2045        }
2046        assert!(condition_compatible(&Type::Never));
2047        assert!(!condition_compatible(&Type::Void));
2048    }
2049
2050    #[test]
2051    fn truthiness_class_table() {
2052        use TruthinessClass::*;
2053        let cases = [
2054            (Type::Null, AlwaysFalsy),
2055            (Type::Boolean, BooleanLike),
2056            (Type::Number, NumberLike),
2057            (
2058                Type::NumberLiteral(crate::types::LiteralF64(0.0)),
2059                AlwaysFalsy,
2060            ),
2061            (
2062                Type::NumberLiteral(crate::types::LiteralF64(-0.0)),
2063                AlwaysFalsy,
2064            ),
2065            (
2066                Type::NumberLiteral(crate::types::LiteralF64(3.0)),
2067                AlwaysTruthy,
2068            ),
2069            (Type::String, StringLike),
2070            (Type::StringLiteral(String::new()), AlwaysFalsy),
2071            (Type::StringLiteral("a".to_string()), AlwaysTruthy),
2072            (Type::BigInt, BigIntLike),
2073            (Type::Uint8Array, AlwaysTruthy),
2074            (Type::Array(Box::new(Type::Number)), AlwaysTruthy),
2075            (Type::Unknown, Dynamic),
2076        ];
2077        for (ty, expected) in cases {
2078            assert_eq!(truthiness_class(&ty), expected, "{ty:?}");
2079        }
2080    }
2081
2082    #[test]
2083    fn truthy_part_strips_null_and_falsy_literals() {
2084        assert_eq!(
2085            truthy_part(&Type::union(vec![Type::String, Type::Null])),
2086            Type::String
2087        );
2088        assert_eq!(
2089            truthy_part(&Type::union(vec![
2090                Type::StringLiteral(String::new()),
2091                Type::StringLiteral("a".to_string()),
2092                Type::Null,
2093            ])),
2094            Type::StringLiteral("a".to_string())
2095        );
2096        assert_eq!(truthy_part(&Type::Null), Type::Never);
2097        assert_eq!(truthy_part(&Type::Boolean), Type::BooleanLiteral(true));
2098        assert_eq!(truthy_part(&Type::Number), Type::Number);
2099    }
2100
2101    #[test]
2102    fn falsy_part_keeps_falsy_capable_members() {
2103        assert_eq!(
2104            falsy_part(&Type::union(vec![Type::String, Type::Null])),
2105            Type::union(vec![Type::StringLiteral(String::new()), Type::Null])
2106        );
2107        assert_eq!(falsy_part(&Type::Number), Type::Number);
2108        assert_eq!(falsy_part(&Type::Boolean), Type::BooleanLiteral(false));
2109        // Never-falsy references drop entirely.
2110        assert_eq!(
2111            falsy_part(&Type::union(vec![
2112                Type::Array(Box::new(Type::Number)),
2113                Type::Null
2114            ])),
2115            Type::Null
2116        );
2117        assert_eq!(
2118            falsy_part(&Type::Array(Box::new(Type::Number))),
2119            Type::Never
2120        );
2121        assert_eq!(falsy_part(&Type::empty_object()), Type::empty_object());
2122    }
2123
2124    #[test]
2125    fn falsy_only_null_distinguishes_reference_unions() {
2126        assert!(falsy_values_are_only_null(&Type::union(vec![
2127            Type::Array(Box::new(Type::Number)),
2128            Type::Null
2129        ])));
2130        assert!(!falsy_values_are_only_null(&Type::union(vec![
2131            Type::String,
2132            Type::Null
2133        ])));
2134        assert!(falsy_values_are_only_null(&Type::Uint8Array));
2135        assert!(!falsy_values_are_only_null(&Type::Number));
2136    }
2137
2138    #[test]
2139    fn intersect_truthy_and_falsy_respect_js_truthiness() {
2140        let string_or_null = Type::union(vec![Type::String, Type::Null]);
2141        assert_eq!(
2142            intersect_with(&string_or_null, TypeFacts::TRUTHY | TypeFacts::NE_NULL),
2143            Type::String
2144        );
2145        // `""` is falsy but not null — the falsy side keeps `string`.
2146        assert_eq!(
2147            intersect_with(&string_or_null, TypeFacts::FALSY | TypeFacts::EQ_NULL),
2148            string_or_null
2149        );
2150        let arr_or_null = Type::union(vec![Type::Array(Box::new(Type::Number)), Type::Null]);
2151        assert_eq!(
2152            intersect_with(&arr_or_null, TypeFacts::FALSY | TypeFacts::EQ_NULL),
2153            Type::Null
2154        );
2155        // Bare NE_NULL stays a pure null test: `x !== null` keeps `""`.
2156        assert_eq!(
2157            intersect_with(&string_or_null, TypeFacts::NE_NULL),
2158            Type::String
2159        );
2160    }
2161}