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/// The literals that name each member of a union of tuples at `position`,
587/// when every member has a literal there. A tuple shorter than `position`
588/// reads `undefined`, as a destructuring pattern reads it. A member whose
589/// position is optional may hold either, so then the position is no
590/// discriminant.
591pub fn tuple_position_discriminant(members: &[Type], position: usize) -> Option<DiscriminantTable> {
592    if members.len() < 2 {
593        return None;
594    }
595    let mut table = DiscriminantTable::default();
596    for (idx, member) in members.iter().enumerate() {
597        let Type::Tuple(elements) = member.peel() else {
598            return None;
599        };
600        let lit = if position >= elements.len() {
601            LiteralValue::Undefined
602        } else if position < elements.required_len() {
603            elements.get(position).and_then(unit_value)?
604        } else {
605            return None;
606        };
607        // Present or not, a position holds one value, so each one names one
608        // member, `undefined` included.
609        table.insert(
610            lit,
611            VariantIdx(u32::try_from(idx).ok()?),
612            UndefinedNames::One,
613        )?;
614    }
615    Some(table)
616}
617
618/// The literal a `case` label compares against: `None` for an expression
619/// label whose type is no single literal.
620pub fn switch_value_literal(value: &crate::TypedSwitchValue) -> Option<LiteralValue> {
621    Some(match value {
622        crate::TypedSwitchValue::String { value, .. } => LiteralValue::String(value.clone()),
623        crate::TypedSwitchValue::Number { value, .. } => {
624            LiteralValue::Number(crate::types::LiteralF64(*value))
625        }
626        crate::TypedSwitchValue::Boolean { value, .. } => LiteralValue::Boolean(*value),
627        crate::TypedSwitchValue::Null { .. } => LiteralValue::Null,
628        crate::TypedSwitchValue::Undefined { .. } => LiteralValue::Undefined,
629        crate::TypedSwitchValue::Expr { literal, .. } => return literal.clone(),
630        crate::TypedSwitchValue::Enum { value, .. } => match value {
631            crate::EnumVariantPayload::Number(n) => {
632                LiteralValue::Number(crate::types::LiteralF64(*n))
633            }
634            crate::EnumVariantPayload::String(s) => LiteralValue::String(s.clone()),
635        },
636    })
637}
638
639/// The literal type a compared literal value has.
640pub fn literal_type(literal: &LiteralValue) -> Type {
641    match literal {
642        LiteralValue::String(s) => Type::StringLiteral(s.clone()),
643        LiteralValue::Number(n) => Type::NumberLiteral(*n),
644        LiteralValue::Boolean(b) => Type::BooleanLiteral(*b),
645        LiteralValue::BigInt(digits) => Type::BigIntLiteral(digits.clone()),
646        LiteralValue::Null => Type::Null,
647        LiteralValue::Undefined => Type::Undefined,
648    }
649}
650
651/// Whether `ty` is, or has a member that is, a literal, `null` or
652/// `undefined`: what makes a property a discriminant in TypeScript. An enum
653/// is the union of its members' literals.
654pub fn has_unit_member(ty: &Type) -> bool {
655    match ty.peel() {
656        Type::Union(members) => members.iter().any(has_unit_member),
657        Type::Null | Type::Undefined | Type::NumberEnum { .. } | Type::StringEnum { .. } => true,
658        other => unit_literal_value(other).is_some(),
659    }
660}
661
662/// Whether `case` labels for the `covered` literals, `null` and `undefined`
663/// included, match every value of `ty`.
664pub fn is_covered_by_literals(ty: &Type, covered: &BTreeSet<LiteralValue>) -> bool {
665    match ty.peel() {
666        Type::Union(members) => members
667            .iter()
668            .all(|member| is_covered_by_literals(member, covered)),
669        // `boolean` is `true | false`.
670        Type::Boolean => [true, false]
671            .into_iter()
672            .all(|value| covered.contains(&LiteralValue::Boolean(value))),
673        other => unit_value(other).is_some_and(|value| covered.contains(&value)),
674    }
675}
676
677/// Whether `ty` is a type parameter, or a union with one.
678pub fn has_type_parameter_member(ty: &Type) -> bool {
679    match ty.peel() {
680        Type::Union(members) => members.iter().any(has_type_parameter_member),
681        other => matches!(other, Type::TypeVar(_) | Type::GenericParam { .. }),
682    }
683}
684
685pub(super) fn unit_literal_value(ty: &Type) -> Option<LiteralValue> {
686    match ty.peel() {
687        Type::StringLiteral(s) => Some(LiteralValue::String(s.clone())),
688        Type::NumberLiteral(n) => Some(LiteralValue::Number(*n)),
689        Type::BooleanLiteral(b) => Some(LiteralValue::Boolean(*b)),
690        Type::BigIntLiteral(digits) => Some(LiteralValue::BigInt(digits.clone())),
691        other => LiteralValue::of_enum_member(other),
692    }
693}
694
695/// [`unit_literal_value`], with `null` and `undefined` as values too: what a
696/// discriminant or a `case` label may name.
697pub(super) fn unit_value(ty: &Type) -> Option<LiteralValue> {
698    match ty.peel() {
699        Type::Null => Some(LiteralValue::Null),
700        Type::Undefined | Type::Void => Some(LiteralValue::Undefined),
701        other => unit_literal_value(other),
702    }
703}
704
705/// Strip `Type::Null` from a union. Returns `Type::Error` for `Type::Null`
706/// itself (empty union has no representation).
707pub fn strip_null(ty: &Type) -> Type {
708    if let Type::Refined {
709        original,
710        ty: shape,
711    } = ty.without_aliases()
712    {
713        return preserve_refinement(original, strip_null(shape));
714    }
715    match ty.peel() {
716        Type::Null => Type::Error,
717        Type::Union(members) => without_nullish_refinements(Type::union(
718            members
719                .iter()
720                .filter(|m| !matches!(m.peel(), Type::Null))
721                .map(|m| {
722                    if is_type_parameter_or_its_narrowing(m) {
723                        strip_null(m)
724                    } else {
725                        m.clone()
726                    }
727                })
728                .collect(),
729        )),
730        // `unknown` is `{} | null | undefined`, as in TypeScript.
731        Type::Unknown => Type::union(vec![Type::empty_object(), Type::Undefined]),
732        // A type parameter may hold any value: `T` minus `null` is
733        // `T & ({} | undefined)`.
734        Type::GenericParam { .. } => preserve_refinement(ty, strip_null(&Type::Unknown)),
735        // `ty` itself, not its peel: peeling drops a `readonly` wrapper.
736        _ => ty.clone(),
737    }
738}
739
740/// Remove only `undefined`, preserving strict equality's distinction from null.
741/// A `void` member stays: a `void` result may hold any value, so only a
742/// `typeof` test narrows it.
743pub fn strip_undefined(ty: &Type) -> Type {
744    if let Type::Refined {
745        original,
746        ty: shape,
747    } = ty.without_aliases()
748    {
749        return preserve_refinement(original, strip_undefined(shape));
750    }
751    match ty.peel() {
752        Type::Undefined => Type::Error,
753        Type::Union(members) => without_nullish_refinements(Type::union(
754            members
755                .iter()
756                .filter(|member| !matches!(member.peel(), Type::Undefined))
757                .map(|member| {
758                    if is_type_parameter_or_its_narrowing(member) {
759                        strip_undefined(member)
760                    } else {
761                        member.clone()
762                    }
763                })
764                .collect(),
765        )),
766        Type::Unknown => Type::union(vec![Type::empty_object(), Type::Null]),
767        Type::GenericParam { .. } => preserve_refinement(ty, strip_undefined(&Type::Unknown)),
768        _ => ty.clone(),
769    }
770}
771
772/// A union member that a nullish test narrows in place: a type parameter, or
773/// a narrowing of one such as `T & ({} | null)`, whose shape `peel` hides.
774fn is_type_parameter_or_its_narrowing(member: &Type) -> bool {
775    matches!(member.peel(), Type::GenericParam { .. }) || member.refines_type_parameter()
776}
777
778/// Remove both nullish alternatives for optional access and non-null assertions.
779pub fn strip_nullish(ty: &Type) -> Type {
780    strip_undefined(&strip_null(ty))
781}
782
783/// `in` retains optional members in both branches: declaring a property does
784/// not prove that its value is defined. A present optional property can still
785/// hold `undefined`, so its read type retains optionality.
786pub fn narrow_field_presence(
787    receiver_ty: &Type,
788    field: &str,
789    lookup: &dyn Fn(&Type, &str) -> Option<crate::ObjectField>,
790) -> (Type, Type) {
791    if let Type::Refined {
792        original,
793        ty: shape,
794    } = receiver_ty.without_aliases()
795    {
796        let (present, absent) = narrow_field_presence(shape, field, lookup);
797        return (
798            preserve_refinement(original, present),
799            preserve_refinement(original, absent),
800        );
801    }
802    match receiver_ty.peel() {
803        Type::Unknown | Type::NonPrimitive => {
804            let fields = BTreeMap::from([(
805                field.to_string(),
806                crate::ObjectField::required(Type::Unknown),
807            )]);
808            (
809                Type::Object {
810                    index: None,
811                    fields,
812                },
813                receiver_ty.clone(),
814            )
815        }
816        Type::Object { fields, .. } => {
817            let mut present = fields.clone();
818            present
819                .entry(field.to_string())
820                .or_insert_with(|| crate::ObjectField::required(Type::Unknown));
821            let absent = match fields.get(field) {
822                Some(f) if !f.optional => Type::Error,
823                _ => receiver_ty.clone(),
824            };
825            (
826                Type::Object {
827                    index: None,
828                    fields: present,
829                },
830                absent,
831            )
832        }
833        Type::Union(members) => {
834            let has = members
835                .iter()
836                .filter(|m| lookup(m, field).is_some())
837                .cloned()
838                .collect();
839            let lacks = members
840                .iter()
841                .filter(|m| lookup(m, field).is_none_or(|f| f.optional))
842                .cloned()
843                .collect();
844            (Type::union(has), Type::union(lacks))
845        }
846        Type::InterfaceRef { .. } | Type::ClassRef { .. } => {
847            let absent = if lookup(receiver_ty, field).is_some_and(|f| !f.optional) {
848                Type::Error
849            } else {
850                receiver_ty.clone()
851            };
852            (receiver_ty.clone(), absent)
853        }
854        _ => (Type::Error, Type::Error),
855    }
856}
857
858/// `BTreeMap`, not `HashMap`, so a walk over an env is reproducible. Under a hash map
859/// `enter_closure_narrow_boundary` would hand the same program different `#narrow_<N>`
860/// numbering and different `ExprId`s per compile, and `install_joined_narrowings`'s
861/// `joined_rebound` and `retain_emittable_views`'s `candidates` would inherit that order.
862/// Only the region nesting reaches the emitted Wasm — [`wrap_order`] pins that, and
863/// locals are unnamed there — so this is defence in depth for everything else.
864#[derive(Clone, Debug, Default)]
865pub struct NarrowEnv {
866    views: BTreeMap<ReferencePath, NarrowedView>,
867    /// Predicate drops belong to this outcome, never to the condition's parent
868    /// scope. A consumer that installs no frame simply discards these records.
869    pub dropped: BTreeMap<ReferencePath, Type>,
870}
871
872impl NarrowEnv {
873    pub fn new() -> Self {
874        Self::default()
875    }
876
877    pub fn insert(&mut self, path: ReferencePath, view: NarrowedView) -> Option<NarrowedView> {
878        self.dropped.remove(&path);
879        self.views.insert(path, view)
880    }
881
882    pub fn extend_env(&mut self, other: Self) {
883        self.dropped.extend(other.dropped);
884        self.extend(other.views);
885    }
886
887    pub fn clear(&mut self) {
888        self.views.clear();
889        self.dropped.clear();
890    }
891}
892
893impl std::ops::Deref for NarrowEnv {
894    type Target = BTreeMap<ReferencePath, NarrowedView>;
895    fn deref(&self) -> &Self::Target {
896        &self.views
897    }
898}
899
900impl std::ops::DerefMut for NarrowEnv {
901    fn deref_mut(&mut self) -> &mut Self::Target {
902        &mut self.views
903    }
904}
905
906impl IntoIterator for NarrowEnv {
907    type Item = (ReferencePath, NarrowedView);
908    type IntoIter = std::collections::btree_map::IntoIter<ReferencePath, NarrowedView>;
909    fn into_iter(self) -> Self::IntoIter {
910        self.views.into_iter()
911    }
912}
913
914impl<'a> IntoIterator for &'a NarrowEnv {
915    type Item = (&'a ReferencePath, &'a NarrowedView);
916    type IntoIter = std::collections::btree_map::Iter<'a, ReferencePath, NarrowedView>;
917    fn into_iter(self) -> Self::IntoIter {
918        self.views.iter()
919    }
920}
921
922impl Extend<(ReferencePath, NarrowedView)> for NarrowEnv {
923    fn extend<I: IntoIterator<Item = (ReferencePath, NarrowedView)>>(&mut self, iter: I) {
924        for (path, view) in iter {
925            self.insert(path, view);
926        }
927    }
928}
929
930impl FromIterator<(ReferencePath, NarrowedView)> for NarrowEnv {
931    fn from_iter<I: IntoIterator<Item = (ReferencePath, NarrowedView)>>(iter: I) -> Self {
932        let mut env = Self::new();
933        env.extend(iter);
934        env
935    }
936}
937
938/// Nesting order for the wrap-the-body-in-one-region-per-path loops: longest chain
939/// first, so every path is wrapped *inside* the prefixes its source reads. Ties break
940/// on the path itself, making this a total order — the emitted nesting must not depend
941/// on the order the caller happened to iterate its env in.
942pub fn wrap_order(a: &ReferencePath, b: &ReferencePath) -> std::cmp::Ordering {
943    b.chain.len().cmp(&a.chain.len()).then_with(|| a.cmp(b))
944}
945
946/// Compares only `narrowed_ty` per path — `binding` and `source` differ
947/// across mints even when the narrowing is unchanged.
948pub fn envs_per_path_narrowed_ty_equal(a: &NarrowEnv, b: &NarrowEnv) -> bool {
949    if a.len() != b.len() {
950        return false;
951    }
952    for (path, a_view) in a {
953        match b.get(path) {
954            Some(b_view) if a_view.narrowed_ty == b_view.narrowed_ty => continue,
955            _ => return false,
956        }
957    }
958    true
959}
960
961/// JS-truthiness classification of a single (non-union) type: which runtime
962/// test decides truthiness for values of that member.
963#[derive(Clone, Copy, Debug, PartialEq, Eq)]
964pub enum TruthinessClass {
965    AlwaysTruthy,
966    AlwaysFalsy,
967    /// Falsy iff `0`, `-0`, or `NaN`. Includes number enums (may hold `0`).
968    NumberLike,
969    /// Falsy iff `""`. Includes string enums (may hold `""`).
970    StringLike,
971    /// Falsy iff `false`.
972    BooleanLike,
973    /// Falsy iff `0n`.
974    BigIntLike,
975    /// Erased at compile time — needs the full runtime dispatch.
976    Dynamic,
977}
978
979pub fn truthiness_class(member: &Type) -> TruthinessClass {
980    use TruthinessClass::*;
981    match member.peel() {
982        Type::Null | Type::Undefined => AlwaysFalsy,
983        Type::Boolean => BooleanLike,
984        Type::BooleanLiteral(true) => AlwaysTruthy,
985        Type::BooleanLiteral(false) => AlwaysFalsy,
986        Type::Number => NumberLike,
987        Type::NumberLiteral(n) => {
988            if n.0 == 0.0 || n.0.is_nan() {
989                AlwaysFalsy
990            } else {
991                AlwaysTruthy
992            }
993        }
994        Type::String => StringLike,
995        Type::StringLiteral(s) => {
996            if s.is_empty() {
997                AlwaysFalsy
998            } else {
999                AlwaysTruthy
1000            }
1001        }
1002        Type::BigInt => BigIntLike,
1003        Type::BigIntLiteral(digits) => {
1004            if digits == "0" {
1005                AlwaysFalsy
1006            } else {
1007                AlwaysTruthy
1008            }
1009        }
1010        Type::NumberEnum {
1011            member: Some(member),
1012            ..
1013        } => truthiness_class(&Type::NumberLiteral(member.value)),
1014        Type::StringEnum {
1015            member: Some(member),
1016            ..
1017        } => truthiness_class(&Type::StringLiteral(member.value.clone())),
1018        Type::NumberEnum { .. } => NumberLike,
1019        Type::StringEnum { .. } => StringLike,
1020        // `{}` admits every value but `null` and `undefined`, falsy primitives
1021        // included.
1022        empty if empty.is_empty_object() => Dynamic,
1023        Type::Object { .. }
1024        | Type::Array(_)
1025        | Type::Tuple(_)
1026        | Type::Function { .. }
1027        | Type::Uint8Array
1028        | Type::InterfaceRef { .. }
1029        | Type::ClassRef { .. }
1030        | Type::NonPrimitive => AlwaysTruthy,
1031        _ => Dynamic,
1032    }
1033}
1034
1035/// Whether every member of `ty` is always truthy or always falsy, so a
1036/// truthiness test that rules a member out rules out every value it holds.
1037pub fn has_known_truthiness(ty: &Type) -> bool {
1038    union_members(ty).into_iter().all(|member| {
1039        matches!(
1040            truthiness_class(member),
1041            TruthinessClass::AlwaysTruthy | TruthinessClass::AlwaysFalsy
1042        )
1043    })
1044}
1045
1046/// The type of `!value` for a `value` of type `ty`, as TypeScript has it:
1047/// `false` when every value it holds is truthy, `true` when every one is
1048/// falsy, and `boolean` otherwise.
1049pub fn negation_type(ty: &Type) -> Type {
1050    let classes: Vec<TruthinessClass> = union_members(ty)
1051        .into_iter()
1052        .map(truthiness_class)
1053        .collect();
1054    if classes
1055        .iter()
1056        .all(|class| *class == TruthinessClass::AlwaysTruthy)
1057    {
1058        return Type::BooleanLiteral(false);
1059    }
1060    if classes
1061        .iter()
1062        .all(|class| *class == TruthinessClass::AlwaysFalsy)
1063    {
1064        return Type::BooleanLiteral(true);
1065    }
1066    Type::Boolean
1067}
1068
1069pub(super) fn union_members(ty: &Type) -> Vec<&Type> {
1070    match ty.peel() {
1071        Type::Union(members) => members.iter().collect(),
1072        // `ty` itself, not its peel: peeling drops a `readonly` wrapper.
1073        _ => vec![ty],
1074    }
1075}
1076
1077/// The type of `x` where `x` is known truthy: drop `null` and falsy literal
1078/// types per member. Falsy-capable base types stay whole (matching TS, which
1079/// doesn't invent a "non-empty string" type).
1080pub fn truthy_part(ty: &Type) -> Type {
1081    if let Type::Refined {
1082        original,
1083        ty: shape,
1084    } = ty.without_aliases()
1085    {
1086        return preserve_refinement(original, truthy_part(shape));
1087    }
1088    // `boolean` is exactly `true | false` (spec §1.2), so its truthy part is `true`.
1089    let kept: Vec<Type> = union_members(ty)
1090        .into_iter()
1091        .filter(|m| truthiness_class(m) != TruthinessClass::AlwaysFalsy)
1092        .map(|m| match m.without_aliases() {
1093            Type::Boolean => Type::BooleanLiteral(true),
1094            // A truthy `T` is `T & {}`, as in TypeScript.
1095            Type::GenericParam { .. } => intersect_with(m, TypeFacts::TRUTHY),
1096            _ => m.clone(),
1097        })
1098        .collect();
1099    Type::union(kept)
1100}
1101
1102/// Whether no value of `ty` is falsy.
1103pub fn is_never_falsy(ty: &Type) -> bool {
1104    union_members(ty)
1105        .into_iter()
1106        .all(|m| truthiness_class(m) == TruthinessClass::AlwaysTruthy)
1107}
1108
1109/// The type of `x` where `x` is known falsy: keep `null` and falsy literals,
1110/// collapse `string` to `""` and `boolean` to `false`, drop never-falsy
1111/// reference types. `{}` stays `{}`: it may hold `0` or `""`, though
1112/// TypeScript's `&&` drops it. `number` stays `number` — a `0` literal would
1113/// be unsound for `NaN`/`-0`.
1114pub fn falsy_part(ty: &Type) -> Type {
1115    if let Type::Refined {
1116        original,
1117        ty: shape,
1118    } = ty.without_aliases()
1119    {
1120        return preserve_refinement(original, falsy_part(shape));
1121    }
1122    let kept: Vec<Type> = union_members(ty)
1123        .into_iter()
1124        .filter_map(|m| match truthiness_class(m) {
1125            TruthinessClass::AlwaysTruthy => None,
1126            TruthinessClass::StringLike => Some(match m.peel() {
1127                Type::String => Type::StringLiteral(String::new()),
1128                _ => m.clone(),
1129            }),
1130            _ => Some(match m.without_aliases() {
1131                Type::Boolean => Type::BooleanLiteral(false),
1132                _ => m.clone(),
1133            }),
1134        })
1135        .collect();
1136    Type::union(kept)
1137}
1138
1139/// True when every falsy value `ty` can hold is `null` — i.e. a truthiness
1140/// test is exactly a null test, so the false branch may assert `EQ_NULL`.
1141pub fn falsy_values_are_only_null(ty: &Type) -> bool {
1142    union_members(ty).into_iter().all(|m| {
1143        matches!(m.peel(), Type::Null) || truthiness_class(m) == TruthinessClass::AlwaysTruthy
1144    })
1145}
1146
1147pub fn type_matches_facts(ty: &Type, facts: TypeFacts) -> bool {
1148    // Aliases are transparent for type identity — peel before matching so an
1149    // aliased primitive/union (e.g. `type NS = number | string`) classifies the
1150    // same as its inline spelling.
1151    let ty = ty.peel();
1152    // TRUTHY/FALSY subsume the null tests (truthy ⇒ non-null), so they are
1153    // checked first: `TRUTHY | NE_NULL` filters by truthiness, bare `NE_NULL`
1154    // stays a pure null test.
1155    if facts.contains(TypeFacts::TRUTHY) {
1156        return truthiness_class(ty) != TruthinessClass::AlwaysFalsy;
1157    }
1158    if facts.contains(TypeFacts::FALSY) {
1159        return truthiness_class(ty) != TruthinessClass::AlwaysTruthy;
1160    }
1161    if facts.contains(TypeFacts::NE_NULL) || facts.contains(TypeFacts::NE_UNDEFINED) {
1162        return !(facts.contains(TypeFacts::NE_NULL) && matches!(ty, Type::Null)
1163            || facts.contains(TypeFacts::NE_UNDEFINED) && matches!(ty, Type::Undefined));
1164    }
1165    // `void` is not `undefined` here: `intersect_with` narrows it on a
1166    // `typeof` test, and `subtract` never rules it out.
1167    if facts.contains(TypeFacts::IS_UNDEFINED) {
1168        return matches!(ty, Type::Undefined);
1169    }
1170    if facts.contains(TypeFacts::EQ_UNDEFINED) {
1171        return matches!(ty, Type::Undefined);
1172    }
1173    if facts.contains(TypeFacts::EQ_NULL) {
1174        return matches!(ty, Type::Null);
1175    }
1176    if facts.contains(TypeFacts::IS_BIGINT) {
1177        return matches!(ty, Type::BigInt);
1178    }
1179    if facts.contains(TypeFacts::IS_NUMBER) {
1180        // An enum member's `typeof` is its underlying primitive, as in TypeScript.
1181        return matches!(
1182            ty,
1183            Type::Number | Type::NumberLiteral(_) | Type::NumberEnum { .. }
1184        );
1185    }
1186    if facts.contains(TypeFacts::IS_STRING) {
1187        return matches!(
1188            ty,
1189            Type::String | Type::StringLiteral(_) | Type::StringEnum { .. }
1190        );
1191    }
1192    if facts.contains(TypeFacts::IS_BOOLEAN) {
1193        return matches!(ty, Type::Boolean | Type::BooleanLiteral(_));
1194    }
1195    if facts.contains(TypeFacts::IS_ARRAY) {
1196        return matches!(ty, Type::Array(_));
1197    }
1198    if facts.contains(TypeFacts::IS_FUNCTION) {
1199        // `object` holds functions too; Submilli has no `Function` type to
1200        // narrow it to, so it stays `object`.
1201        return matches!(ty, Type::Function { .. } | Type::NonPrimitive);
1202    }
1203    if facts.contains(TypeFacts::IS_OBJECT) {
1204        return is_typeof_object(ty);
1205    }
1206    false
1207}
1208
1209/// Does `typeof` answer `"object"` for a value of `ty`?
1210///
1211/// Exhaustive on purpose. `"object"` is the complement of the primitive tags,
1212/// so a type constructor with no arm here is a value with *no* `typeof` at all —
1213/// every one of the five tags folds to `false`, which is unsatisfiable and
1214/// silent. Matching exhaustively makes a new [`Type`] variant fail to compile
1215/// until someone decides its tag.
1216fn is_typeof_object(ty: &Type) -> bool {
1217    match ty {
1218        // A union reaches here from the tuple-element rollup, where the
1219        // question is whether the whole element answers the tag.
1220        Type::Union(members) => members.iter().all(is_typeof_object),
1221
1222        Type::Object { .. }
1223        | Type::Array(_)
1224        | Type::Tuple(_)
1225        | Type::InterfaceRef { .. }
1226        | Type::ClassRef { .. }
1227        | Type::Uint8Array
1228        | Type::NonPrimitive
1229        // `typeof null === "object"` — the JS quirk.
1230        | Type::Null => true,
1231
1232        Type::Undefined
1233        | Type::Number
1234        | Type::NumberLiteral(_)
1235        | Type::NumberEnum { .. }
1236        | Type::String
1237        | Type::StringLiteral(_)
1238        | Type::StringEnum { .. }
1239        | Type::Boolean
1240        | Type::BooleanLiteral(_)
1241        | Type::BigInt
1242        | Type::BigIntLiteral(_)
1243        | Type::Function { .. } => false,
1244
1245        // Not classifiable, for four different reasons: no value at all
1246        // (`Void`, `Never`), a diagnostic already reported (`Error`), erased at
1247        // runtime or bodyless (`TypeVar`, `GenericParam`, `Unknown`, and a
1248        // recursive alias's back-edge `AliasRef`). `fold_typeof_tag` bails on
1249        // the erased set before it ever asks, so `false` here only ever means
1250        // "narrows nothing", never "answers no tag".
1251        Type::Void
1252        | Type::Never
1253        | Type::Error
1254        | Type::Unknown
1255        | Type::TypeVar(_)
1256        | Type::GenericParam { .. }
1257        | Type::AliasRef { .. } => false,
1258
1259        // Peeled by the caller.
1260        Type::Alias { .. } | Type::Refined { .. } | Type::Readonly(_) => false,
1261    }
1262}
1263
1264/// Keep only the union members of `ty` that match `facts`. For a
1265/// non-union `ty`, returns `ty` if matched or [`Type::Error`] if not. A
1266/// `typeof "object"` test is the exception: it turns `unknown`, `{}` and a
1267/// type parameter into the object types they hold ([`typeof_object_narrowing`]).
1268pub fn intersect_with(ty: &Type, facts: TypeFacts) -> Type {
1269    if let Type::Refined {
1270        original,
1271        ty: shape,
1272    } = ty.without_aliases()
1273    {
1274        return preserve_refinement(original, intersect_with(shape, facts));
1275    }
1276    let peeled = ty.peel();
1277    if facts == TypeFacts::IS_OBJECT
1278        && let Some(narrowed) = typeof_object_narrowing(ty, peeled)
1279    {
1280        return narrowed;
1281    }
1282    if let Some(asserted) = asserted_type_for_facts(facts) {
1283        match peeled {
1284            Type::Unknown => return asserted,
1285            // `{}` holds every primitive, so a `typeof` test pins it as it does
1286            // `unknown`. It holds no `null` or `undefined`, so those tests fall
1287            // through and rule it out.
1288            empty
1289                if empty.is_empty_object() && !matches!(asserted, Type::Null | Type::Undefined) =>
1290            {
1291                return asserted;
1292            }
1293            // `Array.isArray` pins an `object` to an array.
1294            Type::NonPrimitive if matches!(asserted, Type::Array(_)) => return asserted,
1295            Type::GenericParam { .. } => {
1296                return Type::Refined {
1297                    original: Box::new(ty.clone()),
1298                    ty: Box::new(asserted),
1299                };
1300            }
1301            _ => {}
1302        }
1303    }
1304    if matches!(peeled, Type::Unknown) && tests_only_presence(facts) {
1305        return recombine_unknown(intersect_with(&unknown_as_union(), facts));
1306    }
1307    if matches!(peeled, Type::GenericParam { .. }) && tests_only_presence(facts) {
1308        return preserve_refinement(ty, intersect_with(&Type::Unknown, facts));
1309    }
1310    match peeled {
1311        Type::Union(members) => without_nullish_refinements(Type::union(
1312            members
1313                .iter()
1314                .map(|m| intersect_with(m, facts))
1315                .filter(|ty| !matches!(ty, Type::Error))
1316                .collect(),
1317        )),
1318        // An explicit typeof test supplies the runtime fact that a void
1319        // value's static type alone cannot establish.
1320        Type::Void if facts.contains(TypeFacts::IS_UNDEFINED) => Type::Undefined,
1321        // `boolean` is `true | false`, and truthiness splits it.
1322        Type::Boolean if facts == TypeFacts::TRUTHY => Type::BooleanLiteral(true),
1323        Type::Boolean if facts == TypeFacts::FALSY => Type::BooleanLiteral(false),
1324        _ if type_matches_facts(peeled, facts) => ty.clone(),
1325        _ if matches!(peeled, Type::Unknown) => Type::Unknown,
1326        _ => Type::Error,
1327    }
1328}
1329
1330/// What `typeof x === "object"` leaves of a type holding values of every
1331/// kind, as in TypeScript: `object | null` of `unknown`, `object` of `{}`,
1332/// and `(T & object) | (T & null)` of a type parameter `T`. `None` for the
1333/// types the general rules narrow.
1334fn typeof_object_narrowing(ty: &Type, peeled: &Type) -> Option<Type> {
1335    match peeled {
1336        Type::Unknown => Some(Type::union(vec![Type::NonPrimitive, Type::Null])),
1337        empty if empty.is_empty_object() => Some(Type::NonPrimitive),
1338        Type::GenericParam { .. } => Some(Type::union(
1339            [Type::NonPrimitive, Type::Null]
1340                .into_iter()
1341                .map(|shape| Type::Refined {
1342                    original: Box::new(ty.clone()),
1343                    ty: Box::new(shape),
1344                })
1345                .collect(),
1346        )),
1347        _ => None,
1348    }
1349}
1350
1351pub fn subtract(ty: &Type, facts: TypeFacts) -> Type {
1352    if let Type::Refined {
1353        original,
1354        ty: shape,
1355    } = ty.without_aliases()
1356    {
1357        return preserve_refinement(original, subtract(shape, facts));
1358    }
1359    let peeled = ty.peel();
1360    // `unknown` minus `null` or `undefined` is the rest of `{} | null |
1361    // undefined`, as in TypeScript; minus any other type it stays `unknown`.
1362    if matches!(peeled, Type::Unknown) {
1363        if tests_only_nullishness(facts) {
1364            return recombine_unknown(subtract(&unknown_as_union(), facts));
1365        }
1366        return Type::Unknown;
1367    }
1368    if matches!(peeled, Type::GenericParam { .. }) {
1369        return preserve_refinement(ty, subtract(&Type::Unknown, facts));
1370    }
1371    // A `void` result may hold any value, so no test rules it out: `typeof
1372    // v !== "undefined"` keeps it, though `typeof v === "undefined"` may
1373    // narrow it to `undefined`.
1374    let ruled_out = |member: &Type| {
1375        !matches!(member.peel(), Type::Void)
1376            && !typeof_tag_keeps(member, facts)
1377            && type_matches_facts(member, facts)
1378    };
1379    match peeled {
1380        Type::Union(members) => {
1381            let kept: Vec<Type> = members.iter().filter(|m| !ruled_out(m)).cloned().collect();
1382            Type::union(kept)
1383        }
1384        _ if !ruled_out(peeled) => ty.clone(),
1385        _ => Type::Error,
1386    }
1387}
1388
1389/// Whether a `typeof` tag in `facts` leaves `member` in place: `{}` holds a
1390/// value of every tag, and `object` holds functions too. As in TypeScript,
1391/// `typeof x !== "object"` does rule out `object`.
1392fn typeof_tag_keeps(member: &Type, facts: TypeFacts) -> bool {
1393    let peeled = member.peel();
1394    let tags_object = facts.contains(TypeFacts::IS_OBJECT);
1395    let tags_function = facts.contains(TypeFacts::IS_FUNCTION);
1396    if peeled.is_empty_object() {
1397        return tags_object || tags_function;
1398    }
1399    tags_function && matches!(peeled, Type::NonPrimitive)
1400}
1401
1402pub(super) fn with_source_refinement(source: &Type, shape: Type) -> Type {
1403    match source.without_aliases() {
1404        Type::Refined { original, .. } => preserve_refinement(original, shape),
1405        _ => shape,
1406    }
1407}
1408
1409pub(super) fn preserve_refinement(original: &Type, shape: Type) -> Type {
1410    if matches!(shape, Type::Error | Type::Never) {
1411        return shape;
1412    }
1413    // A shape that proves nothing leaves the type parameter as it was.
1414    if matches!(shape, Type::Unknown) {
1415        return original.clone();
1416    }
1417    Type::Refined {
1418        original: Box::new(original.clone()),
1419        ty: Box::new(shape),
1420    }
1421}
1422
1423/// Strip covered literal values from `ty`. When the residual is `Type::Never`,
1424/// all discriminant values were covered by `case` labels.
1425///
1426/// - **Union**: filter out covered literal members, keeping the rest (`number`
1427///   in `number | "a"`); collapses to `Never` when all drop.
1428/// - **Single literal**: `Never` if covered, unchanged otherwise.
1429/// - **Anything else**: returned unchanged.
1430pub fn subtract_literals(ty: &Type, covered: &BTreeSet<LiteralValue>) -> Type {
1431    if let Type::Refined {
1432        original,
1433        ty: shape,
1434    } = ty.without_aliases()
1435    {
1436        return preserve_refinement(original, subtract_literals(shape, covered));
1437    }
1438    match ty.peel() {
1439        Type::Union(members) => {
1440            let kept: Vec<Type> = members
1441                .iter()
1442                .map(|m| subtract_literals(m, covered))
1443                .collect();
1444            Type::union(kept)
1445        }
1446        // `boolean` is `true | false`, so `case true:` leaves `false`.
1447        Type::Boolean => {
1448            let remaining: Vec<Type> = [false, true]
1449                .into_iter()
1450                .filter(|b| !covered.contains(&LiteralValue::Boolean(*b)))
1451                .map(Type::BooleanLiteral)
1452                .collect();
1453            Type::union(remaining)
1454        }
1455        single @ (Type::StringLiteral(_)
1456        | Type::NumberLiteral(_)
1457        | Type::BooleanLiteral(_)
1458        | Type::BigIntLiteral(_)
1459        | Type::Null
1460        | Type::Undefined) => match unit_value(single) {
1461            Some(lit) if covered.contains(&lit) => Type::Never,
1462            _ => ty.clone(),
1463        },
1464        _ => ty.clone(),
1465    }
1466}
1467
1468/// Whether `facts` test only whether a value is present: truthiness, `null`
1469/// and `undefined`. Against those, `unknown` narrows as `{} | null |
1470/// undefined`, which is what TypeScript takes it to mean.
1471fn tests_only_presence(facts: TypeFacts) -> bool {
1472    let presence = TypeFacts::TRUTHY
1473        | TypeFacts::FALSY
1474        | TypeFacts::EQ_NULL
1475        | TypeFacts::NE_NULL
1476        | TypeFacts::EQ_UNDEFINED
1477        | TypeFacts::NE_UNDEFINED
1478        | TypeFacts::IS_UNDEFINED;
1479    facts != TypeFacts::EMPTY && presence.contains(facts)
1480}
1481
1482/// Whether `facts` test only for `null` or `undefined`, not truthiness.
1483fn tests_only_nullishness(facts: TypeFacts) -> bool {
1484    tests_only_presence(facts)
1485        && !facts.contains(TypeFacts::TRUTHY)
1486        && !facts.contains(TypeFacts::FALSY)
1487}
1488
1489/// `union` with each refined type parameter's own `null` or `undefined`
1490/// dropped where the union holds that value already, as TypeScript reduces
1491/// `(T & ({} | null)) | null` to `(T & {}) | null`.
1492fn without_nullish_refinements(union: Type) -> Type {
1493    let Type::Union(members) = &union else {
1494        return union;
1495    };
1496    let holds = |value: &Type| members.iter().any(|member| member.peel() == value);
1497    let (holds_null, holds_undefined) = (holds(&Type::Null), holds(&Type::Undefined));
1498    if !holds_null && !holds_undefined {
1499        return union;
1500    }
1501    Type::union(
1502        members
1503            .iter()
1504            .filter_map(|member| match member {
1505                Type::Refined { original, ty } => {
1506                    let mut shape = (**ty).clone();
1507                    if holds_null {
1508                        shape = strip_null(&shape);
1509                    }
1510                    if holds_undefined {
1511                        shape = strip_undefined(&shape);
1512                    }
1513                    // `T & null` beside `null` adds nothing.
1514                    (!is_ruled_out(&shape)).then(|| preserve_refinement(original, shape))
1515                }
1516                other => Some(other.clone()),
1517            })
1518            .collect(),
1519    )
1520}
1521
1522/// `unknown` as the values it holds: `{} | null | undefined`.
1523fn unknown_as_union() -> Type {
1524    Type::Union(vec![Type::empty_object(), Type::Null, Type::Undefined])
1525}
1526
1527/// What is left of `unknown` after a narrowing: `unknown` again when the
1528/// narrowing ruled nothing out, as TypeScript's `recombineUnknownType` does.
1529fn recombine_unknown(ty: Type) -> Type {
1530    if ty.holds_every_value() {
1531        return Type::Unknown;
1532    }
1533    ty
1534}
1535
1536/// Map a [`TypeFacts`] mask to the concrete `Type` it asserts when narrowing
1537/// `unknown`. Returns `None` when facts don't pin a single type (TRUTHY,
1538/// IS_OBJECT span multiple types).
1539fn asserted_type_for_facts(facts: TypeFacts) -> Option<Type> {
1540    if facts.contains(TypeFacts::IS_BIGINT) {
1541        return Some(Type::BigInt);
1542    }
1543    if facts.contains(TypeFacts::IS_NUMBER) {
1544        return Some(Type::Number);
1545    }
1546    if facts.contains(TypeFacts::IS_STRING) {
1547        return Some(Type::String);
1548    }
1549    if facts.contains(TypeFacts::IS_BOOLEAN) {
1550        return Some(Type::Boolean);
1551    }
1552    if facts.contains(TypeFacts::IS_ARRAY) {
1553        return Some(Type::Array(Box::new(Type::Unknown)));
1554    }
1555    if facts.contains(TypeFacts::EQ_NULL) {
1556        return Some(Type::Null);
1557    }
1558    if facts.contains(TypeFacts::EQ_UNDEFINED) || facts.contains(TypeFacts::IS_UNDEFINED) {
1559        return Some(Type::Undefined);
1560    }
1561    // IS_OBJECT admits arrays, objects, and null (the JS typeof-null quirk);
1562    // no single concrete type, so caller keeps `Unknown`.
1563    None
1564}
1565
1566/// Join two branch post-environments at a control-flow merge point:
1567///
1568/// - Paths narrowed in **both** branches: joined type is `Type::union` of the two.
1569/// - Paths narrowed in only one branch: dropped.
1570/// - Paths a branch *extended* by assignment (`o` assigned, `o.f` narrowed):
1571///   dropped — the write can invalidate everything below it.
1572///
1573/// An assignment to the narrowed path *itself* does not drop it: each branch's
1574/// env already carries what that branch's assignment left behind
1575/// (`install_assignment_narrowing` records the assigned type), so the union of
1576/// the two is exactly the post-join type. That is what makes
1577/// `if (s === null) { s = "x"; }` leave `s: string` behind.
1578///
1579/// Assumes both branches contributed; caller handles the unreachable-branch case.
1580pub fn union_envs(
1581    a_narrowings: NarrowEnv,
1582    a_assigned: BTreeSet<ReferencePath>,
1583    b_narrowings: NarrowEnv,
1584    b_assigned: BTreeSet<ReferencePath>,
1585) -> (NarrowEnv, BTreeSet<ReferencePath>) {
1586    let mut joined_narrowings = NarrowEnv::new();
1587    for (path, a_view) in &a_narrowings {
1588        if let Some(b_view) = b_narrowings.get(path) {
1589            let Some(joined_ty) = join_narrowed_types(&a_view.narrowed_ty, &b_view.narrowed_ty)
1590            else {
1591                continue;
1592            };
1593            // A ruled-out view has no shadow, so the join reads through the other.
1594            let a_view = if is_ruled_out(&a_view.narrowed_ty) {
1595                b_view
1596            } else {
1597                a_view
1598            };
1599            joined_narrowings.insert(
1600                path.clone(),
1601                NarrowedView {
1602                    narrowed_ty: joined_ty,
1603                    // Facts are per-branch; the join can't preserve
1604                    // them precisely. The downstream slim-slice
1605                    // sites only consume `narrowed_ty`, so EMPTY is
1606                    // sound.
1607                    facts: TypeFacts::EMPTY,
1608                    excluded_literals: BTreeSet::new(),
1609                    // Reuse `a`'s binding + source. The join
1610                    // typically gets dropped by the assigned-paths
1611                    // filter below; if it survives, the binding /
1612                    // source remain valid since both branches saw
1613                    // the same path.
1614                    binding: a_view.binding.clone(),
1615                    source: a_view.source,
1616                },
1617            );
1618        }
1619    }
1620
1621    for (path, ty) in a_narrowings.dropped.iter().chain(&b_narrowings.dropped) {
1622        let left_ty = a_narrowings
1623            .dropped
1624            .get(path)
1625            .or_else(|| a_narrowings.get(path).map(|view| &view.narrowed_ty));
1626        let right_ty = b_narrowings
1627            .dropped
1628            .get(path)
1629            .or_else(|| b_narrowings.get(path).map(|view| &view.narrowed_ty));
1630        if left_ty == Some(ty) && right_ty == Some(ty) {
1631            joined_narrowings.dropped.insert(path.clone(), ty.clone());
1632        }
1633    }
1634
1635    let mut joined_assigned = a_assigned;
1636    joined_assigned.extend(b_assigned);
1637
1638    joined_narrowings.dropped.retain(|key, _| {
1639        !joined_assigned
1640            .iter()
1641            .any(|assigned| assigned.is_prefix_of(key))
1642    });
1643    joined_narrowings.retain(|key, _| {
1644        !joined_assigned
1645            .iter()
1646            .any(|assigned| assigned != key && assigned.is_prefix_of(key))
1647    });
1648
1649    (joined_narrowings, joined_assigned)
1650}
1651
1652/// The tuple position a number names, if it names one.
1653pub(super) fn tuple_position(index: f64) -> Option<usize> {
1654    (index >= 0.0 && index.fract() == 0.0 && index <= u32::MAX as f64).then_some(index as usize)
1655}
1656
1657/// The type of a view whose guard ruled out every value. Narrowing yields
1658/// `Error` when nothing is left; a view keeps it, rather than `never`, so
1659/// codegen gives the path no shadow local, and a read of the path turns it
1660/// into `never` where `Inferer::rules_out_to_never` allows.
1661pub const RULED_OUT: Type = Type::Error;
1662
1663/// Whether a narrowed type is [`RULED_OUT`].
1664pub fn is_ruled_out(ty: &Type) -> bool {
1665    matches!(ty, Type::Error)
1666}
1667
1668/// The join of two narrowings of one path, or `None` when it holds every
1669/// value: then it narrows nothing, and reads see the declared type again,
1670/// `unknown` or a written `{} | null | undefined`.
1671pub(super) fn join_narrowed_types(left: &Type, right: &Type) -> Option<Type> {
1672    let joined = join_flow_types(left, right);
1673    (!joined.holds_every_value()).then_some(joined)
1674}
1675
1676/// Flow joins collapse a literal already covered by a broad primitive. Keep
1677/// authored unions unchanged: their overlap is meaningful to JSON diagnostics.
1678fn join_flow_types(left: &Type, right: &Type) -> Type {
1679    // A ruled-out side holds no value, so it adds nothing to the other.
1680    if is_ruled_out(left) {
1681        return right.clone();
1682    }
1683    if is_ruled_out(right) {
1684        return left.clone();
1685    }
1686    let joined = Type::union(vec![left.clone(), right.clone()]);
1687    let Type::Union(members) = joined else {
1688        return joined;
1689    };
1690    let mut members = crate::types::without_members_of_empty_object(members);
1691    let has_number = members
1692        .iter()
1693        .any(|ty| matches!(ty.without_aliases(), Type::Number));
1694    let has_string = members
1695        .iter()
1696        .any(|ty| matches!(ty.without_aliases(), Type::String));
1697    members.retain(|ty| match ty.without_aliases() {
1698        Type::NumberLiteral(_) => !has_number,
1699        Type::StringLiteral(_) => !has_string,
1700        _ => true,
1701    });
1702    Type::union(members)
1703}
1704
1705/// Recurses into `Type::Union` members only — composite slots have fixed runtime
1706/// classification regardless of generic contents, so their type arguments are
1707/// not walked either (`Box<T> = number` classifies as a number whatever `T` is).
1708///
1709/// Peeled: an alias of a type parameter is erased just as the parameter is, and
1710/// the caller uses this as the escape hatch that keeps a `typeof` guard on an
1711/// erased operand from folding to a constant.
1712pub fn has_erased_member(ty: &Type) -> bool {
1713    match ty.peel() {
1714        Type::TypeVar(_) | Type::GenericParam { .. } => true,
1715        Type::Union(members) => members.iter().any(has_erased_member),
1716        _ => false,
1717    }
1718}
1719
1720/// Every type is condition-compatible under JS truthiness except `unknown`
1721/// (requires explicit narrowing first, spec §2.11) and the value-less bare
1722/// `void`. A union with another value-bearing alternative is legal; its void
1723/// member remains dynamic. `never` counts, as in TypeScript: either no value
1724/// reaches the test (a call that throws, a local after an exhausted `else if`
1725/// chain), or the value is one a `never[]` an alias filled holds, which tests
1726/// as it would anywhere. `Error` is accepted to silence cascades.
1727pub fn condition_compatible(ty: &Type) -> bool {
1728    !matches!(ty.peel(), Type::Unknown | Type::Void)
1729}
1730
1731pub fn facts_for_target_type(target: &Type) -> TypeFacts {
1732    match target.peel() {
1733        Type::Number | Type::NumberLiteral(_) => TypeFacts::IS_NUMBER,
1734        Type::String | Type::StringLiteral(_) => TypeFacts::IS_STRING,
1735        Type::Boolean | Type::BooleanLiteral(_) => TypeFacts::IS_BOOLEAN,
1736        Type::Null => TypeFacts::EQ_NULL,
1737        Type::Undefined => TypeFacts::EQ_UNDEFINED,
1738        Type::BigInt => TypeFacts::IS_BIGINT,
1739        Type::Array(_) => TypeFacts::IS_ARRAY,
1740        _ => TypeFacts::EMPTY,
1741    }
1742}
1743
1744#[cfg(test)]
1745mod analyzer_tests {
1746    use super::*;
1747
1748    #[test]
1749    fn typeof_undefined_splits_void_without_assuming_its_truthiness() {
1750        let ty = Type::union(vec![Type::Boolean, Type::Void]);
1751        assert_eq!(
1752            intersect_with(&ty, TypeFacts::IS_UNDEFINED),
1753            Type::Undefined
1754        );
1755        assert_eq!(subtract(&ty, TypeFacts::IS_UNDEFINED), ty);
1756        assert_eq!(truthiness_class(&Type::Void), TruthinessClass::Dynamic);
1757        assert!(!type_matches_facts(&Type::Void, TypeFacts::EQ_UNDEFINED));
1758    }
1759
1760    #[test]
1761    fn undefined_facts_are_distinct_from_null() {
1762        let ty = Type::union(vec![Type::String, Type::Null, Type::Undefined]);
1763        assert_eq!(
1764            strip_null(&ty),
1765            Type::union(vec![Type::String, Type::Undefined])
1766        );
1767        assert_eq!(
1768            strip_undefined(&ty),
1769            Type::union(vec![Type::String, Type::Null])
1770        );
1771        assert_eq!(strip_nullish(&ty), Type::String);
1772        assert_eq!(
1773            intersect_with(&ty, TypeFacts::EQ_UNDEFINED),
1774            Type::Undefined
1775        );
1776        assert_eq!(
1777            intersect_with(&Type::Unknown, TypeFacts::EQ_UNDEFINED),
1778            Type::Undefined
1779        );
1780        assert_eq!(
1781            intersect_with(&ty, TypeFacts::NE_NULL | TypeFacts::NE_UNDEFINED),
1782            Type::String
1783        );
1784        assert_eq!(truthy_part(&ty), Type::String);
1785        assert_eq!(
1786            truthiness_class(&Type::Undefined),
1787            TruthinessClass::AlwaysFalsy
1788        );
1789        assert!(!type_matches_facts(&Type::Undefined, TypeFacts::IS_OBJECT));
1790    }
1791
1792    #[test]
1793    fn presence_keeps_optional_read_undefined() {
1794        let ty = Type::Object {
1795            fields: BTreeMap::from([("value".into(), crate::ObjectField::optional(Type::Number))]),
1796            index: None,
1797        };
1798        let (present, absent) = narrow_field_presence(&ty, "value", &|_, _| None);
1799        assert_eq!(present, ty);
1800        assert_eq!(absent, ty);
1801    }
1802
1803    #[test]
1804    fn refined_literal_residual_retains_generic_identity() {
1805        let original = Type::GenericParam {
1806            id: 1,
1807            name: "T".into(),
1808        };
1809        let shape = Type::union(vec![
1810            Type::StringLiteral("a".into()),
1811            Type::StringLiteral("b".into()),
1812        ]);
1813        let refined = preserve_refinement(&original, shape);
1814        let covered = BTreeSet::from([LiteralValue::String("a".into())]);
1815        assert_eq!(
1816            subtract_literals(&refined, &covered),
1817            preserve_refinement(&original, Type::StringLiteral("b".into()))
1818        );
1819    }
1820
1821    #[test]
1822    fn strip_null_from_nullable_string() {
1823        let ty = Type::union(vec![Type::String, Type::Null]);
1824        assert_eq!(strip_null(&ty), Type::String);
1825    }
1826
1827    #[test]
1828    fn strip_null_from_non_nullable_is_identity() {
1829        assert_eq!(strip_null(&Type::String), Type::String);
1830        assert_eq!(strip_null(&Type::Number), Type::Number);
1831    }
1832
1833    #[test]
1834    fn strip_null_from_null_is_error() {
1835        assert_eq!(strip_null(&Type::Null), Type::Error);
1836    }
1837
1838    #[test]
1839    fn strip_null_from_multi_member_union() {
1840        let ty = Type::union(vec![Type::String, Type::Number, Type::Null]);
1841        let stripped = strip_null(&ty);
1842        // Type::union canonicalizes; should be String | Number.
1843        match stripped {
1844            Type::Union(members) => assert_eq!(members.len(), 2),
1845            _ => panic!("expected Union, got {stripped:?}"),
1846        }
1847    }
1848
1849    #[test]
1850    fn intersect_with_is_number_filters_union() {
1851        let ty = Type::union(vec![Type::Number, Type::String, Type::Boolean]);
1852        let kept = intersect_with(&ty, TypeFacts::IS_NUMBER);
1853        assert_eq!(kept, Type::Number);
1854    }
1855
1856    #[test]
1857    fn subtract_is_number_keeps_others() {
1858        let ty = Type::union(vec![Type::Number, Type::String, Type::Boolean]);
1859        let kept = subtract(&ty, TypeFacts::IS_NUMBER);
1860        match kept {
1861            Type::Union(members) => {
1862                assert_eq!(members.len(), 2);
1863                assert!(members.contains(&Type::String));
1864                assert!(members.contains(&Type::Boolean));
1865            }
1866            _ => panic!("expected Union, got {kept:?}"),
1867        }
1868    }
1869
1870    #[test]
1871    fn intersect_with_truthy_strips_null() {
1872        let ty = Type::union(vec![Type::String, Type::Null]);
1873        let kept = intersect_with(&ty, TypeFacts::TRUTHY);
1874        assert_eq!(kept, Type::String);
1875    }
1876
1877    #[test]
1878    fn subtract_truthy_keeps_only_null() {
1879        let ty = Type::union(vec![Type::String, Type::Null]);
1880        let kept = subtract(&ty, TypeFacts::TRUTHY);
1881        assert_eq!(kept, Type::Null);
1882    }
1883
1884    #[test]
1885    fn intersect_with_no_match_yields_error() {
1886        let kept = intersect_with(&Type::String, TypeFacts::IS_NUMBER);
1887        assert_eq!(kept, Type::Error);
1888    }
1889
1890    #[test]
1891    fn facts_for_target_type_covers_primitives() {
1892        assert_eq!(facts_for_target_type(&Type::Number), TypeFacts::IS_NUMBER);
1893        assert_eq!(facts_for_target_type(&Type::String), TypeFacts::IS_STRING);
1894        assert_eq!(facts_for_target_type(&Type::Boolean), TypeFacts::IS_BOOLEAN);
1895        assert_eq!(facts_for_target_type(&Type::Null), TypeFacts::EQ_NULL);
1896    }
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901    use super::*;
1902    use crate::Span;
1903
1904    #[test]
1905    fn reference_path_equality_is_structural() {
1906        let a = ReferencePath::root(BindingId::Local {
1907            name: "x".to_string(),
1908            decl_scope: ScopeId(1),
1909        });
1910        let b = ReferencePath::root(BindingId::Local {
1911            name: "x".to_string(),
1912            decl_scope: ScopeId(1),
1913        });
1914        assert_eq!(a, b);
1915    }
1916
1917    #[test]
1918    fn reference_path_distinguishes_scopes() {
1919        let a = ReferencePath::root(BindingId::Local {
1920            name: "x".to_string(),
1921            decl_scope: ScopeId(1),
1922        });
1923        let b = ReferencePath::root(BindingId::Local {
1924            name: "x".to_string(),
1925            decl_scope: ScopeId(2),
1926        });
1927        assert_ne!(a, b);
1928    }
1929
1930    #[test]
1931    fn reference_path_distinguishes_chain() {
1932        let a = ReferencePath {
1933            root: BindingId::Local {
1934                name: "x".to_string(),
1935                decl_scope: ScopeId(1),
1936            },
1937            chain: vec![PathElem::Field("foo".to_string())],
1938        };
1939        let b = ReferencePath::root(BindingId::Local {
1940            name: "x".to_string(),
1941            decl_scope: ScopeId(1),
1942        });
1943        assert_ne!(a, b);
1944    }
1945
1946    #[test]
1947    fn type_facts_or_combines() {
1948        let f = TypeFacts::TRUTHY | TypeFacts::NE_NULL;
1949        assert!(f.contains(TypeFacts::TRUTHY));
1950        assert!(f.contains(TypeFacts::NE_NULL));
1951        assert!(!f.contains(TypeFacts::EQ_NULL));
1952    }
1953
1954    #[test]
1955    fn type_facts_not_complements() {
1956        let f = TypeFacts::EQ_NULL;
1957        let flipped = !f;
1958        assert!(flipped.contains(TypeFacts::NE_NULL));
1959        assert!(!flipped.contains(TypeFacts::EQ_NULL));
1960    }
1961
1962    #[test]
1963    fn binding_id_global_uses_mangled_name() {
1964        let g = BindingId::Global(crate::mangle::package_symbol("main", "x"));
1965        if let BindingId::Global(name) = &g {
1966            assert_eq!(name.as_str(), "main#x");
1967        } else {
1968            panic!("expected Global");
1969        }
1970    }
1971
1972    #[test]
1973    fn narrowed_view_construction() {
1974        let view = NarrowedView {
1975            narrowed_ty: Type::String,
1976            facts: TypeFacts::NE_NULL,
1977            excluded_literals: BTreeSet::new(),
1978            binding: Ident {
1979                name: "#narrow_0".to_string(),
1980                span: Span::new(crate::FileId(0), 0, 1).unwrap(),
1981            },
1982            source: crate::ExprId(0),
1983        };
1984        assert_eq!(view.narrowed_ty, Type::String);
1985        assert!(view.facts.contains(TypeFacts::NE_NULL));
1986    }
1987
1988    #[test]
1989    fn cast_info_holds_kind_and_types() {
1990        let info = CastInfo {
1991            from_ty: Type::Union(vec![Type::String, Type::Null]),
1992            to_ty: Type::String,
1993            cast_kind: CastKind::NonNull,
1994        };
1995        assert!(matches!(info.cast_kind, CastKind::NonNull));
1996    }
1997
1998    #[test]
1999    fn union_discriminant_stub_returns_none() {
2000        assert!(union_discriminant(&[Type::String, Type::Null]).is_none());
2001    }
2002
2003    #[test]
2004    fn intersect_unknown_with_is_string() {
2005        assert_eq!(
2006            intersect_with(&Type::Unknown, TypeFacts::IS_STRING),
2007            Type::String
2008        );
2009    }
2010
2011    #[test]
2012    fn intersect_unknown_with_is_number() {
2013        assert_eq!(
2014            intersect_with(&Type::Unknown, TypeFacts::IS_NUMBER),
2015            Type::Number
2016        );
2017    }
2018
2019    #[test]
2020    fn intersect_unknown_with_is_boolean() {
2021        assert_eq!(
2022            intersect_with(&Type::Unknown, TypeFacts::IS_BOOLEAN),
2023            Type::Boolean
2024        );
2025    }
2026
2027    #[test]
2028    fn intersect_unknown_with_is_array_carries_unknown_element() {
2029        // `Array.isArray<T>(unknown)` narrows to `Array<unknown>`
2030        // because the element type isn't statically determined.
2031        assert_eq!(
2032            intersect_with(&Type::Unknown, TypeFacts::IS_ARRAY),
2033            Type::Array(Box::new(Type::Unknown)),
2034        );
2035    }
2036
2037    #[test]
2038    fn intersect_unknown_with_eq_null() {
2039        assert_eq!(
2040            intersect_with(&Type::Unknown, TypeFacts::EQ_NULL),
2041            Type::Null
2042        );
2043    }
2044
2045    #[test]
2046    fn intersect_unknown_with_truthy_is_empty_object() {
2047        // A truthy `unknown` is neither `null` nor `undefined`: `{}`.
2048        assert_eq!(
2049            intersect_with(&Type::Unknown, TypeFacts::TRUTHY),
2050            Type::empty_object()
2051        );
2052    }
2053
2054    #[test]
2055    fn subtract_unknown_removes_only_nullish_values() {
2056        // `unknown` minus a type other than `null` or `undefined` stays
2057        // `unknown`; minus those, it is the rest of `{} | null | undefined`.
2058        assert_eq!(
2059            subtract(&Type::Unknown, TypeFacts::IS_STRING),
2060            Type::Unknown
2061        );
2062        assert_eq!(
2063            subtract(&Type::Unknown, TypeFacts::EQ_NULL),
2064            Type::union(vec![Type::empty_object(), Type::Undefined])
2065        );
2066        assert_eq!(subtract(&Type::Unknown, TypeFacts::TRUTHY), Type::Unknown);
2067    }
2068
2069    #[test]
2070    fn strip_null_from_unknown_leaves_empty_object_or_undefined() {
2071        assert_eq!(
2072            strip_null(&Type::Unknown),
2073            Type::union(vec![Type::empty_object(), Type::Undefined])
2074        );
2075        assert_eq!(strip_nullish(&Type::Unknown), Type::empty_object());
2076    }
2077
2078    #[test]
2079    fn condition_compatible_rejects_unknown() {
2080        assert!(!condition_compatible(&Type::Unknown));
2081    }
2082
2083    #[test]
2084    fn condition_compatible_accepts_js_truthiness_types() {
2085        for ty in [
2086            Type::Boolean,
2087            Type::String,
2088            Type::Number,
2089            Type::BigInt,
2090            Type::Null,
2091            Type::Array(Box::new(Type::Number)),
2092            Type::union(vec![Type::String, Type::Null]),
2093            Type::union(vec![Type::Number, Type::Void]),
2094        ] {
2095            assert!(condition_compatible(&ty), "{ty:?}");
2096        }
2097        assert!(condition_compatible(&Type::Never));
2098        assert!(!condition_compatible(&Type::Void));
2099    }
2100
2101    #[test]
2102    fn truthiness_class_table() {
2103        use TruthinessClass::*;
2104        let cases = [
2105            (Type::Null, AlwaysFalsy),
2106            (Type::Boolean, BooleanLike),
2107            (Type::Number, NumberLike),
2108            (
2109                Type::NumberLiteral(crate::types::LiteralF64(0.0)),
2110                AlwaysFalsy,
2111            ),
2112            (
2113                Type::NumberLiteral(crate::types::LiteralF64(-0.0)),
2114                AlwaysFalsy,
2115            ),
2116            (
2117                Type::NumberLiteral(crate::types::LiteralF64(3.0)),
2118                AlwaysTruthy,
2119            ),
2120            (Type::String, StringLike),
2121            (Type::StringLiteral(String::new()), AlwaysFalsy),
2122            (Type::StringLiteral("a".to_string()), AlwaysTruthy),
2123            (Type::BigInt, BigIntLike),
2124            (Type::Uint8Array, AlwaysTruthy),
2125            (Type::Array(Box::new(Type::Number)), AlwaysTruthy),
2126            (Type::Unknown, Dynamic),
2127        ];
2128        for (ty, expected) in cases {
2129            assert_eq!(truthiness_class(&ty), expected, "{ty:?}");
2130        }
2131    }
2132
2133    #[test]
2134    fn truthy_part_strips_null_and_falsy_literals() {
2135        assert_eq!(
2136            truthy_part(&Type::union(vec![Type::String, Type::Null])),
2137            Type::String
2138        );
2139        assert_eq!(
2140            truthy_part(&Type::union(vec![
2141                Type::StringLiteral(String::new()),
2142                Type::StringLiteral("a".to_string()),
2143                Type::Null,
2144            ])),
2145            Type::StringLiteral("a".to_string())
2146        );
2147        assert_eq!(truthy_part(&Type::Null), Type::Never);
2148        assert_eq!(truthy_part(&Type::Boolean), Type::BooleanLiteral(true));
2149        assert_eq!(truthy_part(&Type::Number), Type::Number);
2150    }
2151
2152    #[test]
2153    fn falsy_part_keeps_falsy_capable_members() {
2154        assert_eq!(
2155            falsy_part(&Type::union(vec![Type::String, Type::Null])),
2156            Type::union(vec![Type::StringLiteral(String::new()), Type::Null])
2157        );
2158        assert_eq!(falsy_part(&Type::Number), Type::Number);
2159        assert_eq!(falsy_part(&Type::Boolean), Type::BooleanLiteral(false));
2160        // Never-falsy references drop entirely.
2161        assert_eq!(
2162            falsy_part(&Type::union(vec![
2163                Type::Array(Box::new(Type::Number)),
2164                Type::Null
2165            ])),
2166            Type::Null
2167        );
2168        assert_eq!(
2169            falsy_part(&Type::Array(Box::new(Type::Number))),
2170            Type::Never
2171        );
2172        assert_eq!(falsy_part(&Type::empty_object()), Type::empty_object());
2173    }
2174
2175    #[test]
2176    fn falsy_only_null_distinguishes_reference_unions() {
2177        assert!(falsy_values_are_only_null(&Type::union(vec![
2178            Type::Array(Box::new(Type::Number)),
2179            Type::Null
2180        ])));
2181        assert!(!falsy_values_are_only_null(&Type::union(vec![
2182            Type::String,
2183            Type::Null
2184        ])));
2185        assert!(falsy_values_are_only_null(&Type::Uint8Array));
2186        assert!(!falsy_values_are_only_null(&Type::Number));
2187    }
2188
2189    #[test]
2190    fn intersect_truthy_and_falsy_respect_js_truthiness() {
2191        let string_or_null = Type::union(vec![Type::String, Type::Null]);
2192        assert_eq!(
2193            intersect_with(&string_or_null, TypeFacts::TRUTHY | TypeFacts::NE_NULL),
2194            Type::String
2195        );
2196        // `""` is falsy but not null — the falsy side keeps `string`.
2197        assert_eq!(
2198            intersect_with(&string_or_null, TypeFacts::FALSY | TypeFacts::EQ_NULL),
2199            string_or_null
2200        );
2201        let arr_or_null = Type::union(vec![Type::Array(Box::new(Type::Number)), Type::Null]);
2202        assert_eq!(
2203            intersect_with(&arr_or_null, TypeFacts::FALSY | TypeFacts::EQ_NULL),
2204            Type::Null
2205        );
2206        // Bare NE_NULL stays a pure null test: `x !== null` keeps `""`.
2207        assert_eq!(
2208            intersect_with(&string_or_null, TypeFacts::NE_NULL),
2209            Type::String
2210        );
2211    }
2212}