Skip to main content

tsz_solver/
sound.rs

1//! Sound Mode: Strict type checking beyond TypeScript's defaults.
2//!
3//! TypeScript's type system has intentional unsoundness for pragmatic reasons.
4//! Sound Mode provides opt-in stricter checking that catches common bugs.
5//!
6//! ## Activation
7//!
8//! - CLI: `tsz check --sound`
9//! - tsconfig.json: `{ "compilerOptions": { "sound": true } }`
10//! - Per-file pragma: `// @ts-sound`
11//!
12//! ## What Sound Mode Catches
13//!
14//! | Issue | TypeScript | Sound Mode |
15//! |-------|-----------|------------|
16//! | Covariant mutable arrays | ✅ Allowed | ❌ TS9002 |
17//! | Method parameter bivariance | ✅ Allowed | ❌ TS9003 |
18//! | `any` escapes | ✅ Allowed | ❌ TS9004 |
19//! | Excess property bypass | ✅ Allowed | ❌ TS9001 |
20//! | Enum-number assignment | ✅ Allowed | ❌ TS9005 |
21//!
22//! ## Sticky Freshness
23//!
24//! TypeScript's excess property checking has a bypass:
25//!
26//! ```typescript
27//! const point3d = { x: 1, y: 2, z: 3 };
28//! const point2d: { x: number; y: number } = point3d; // ✅ No error!
29//! ```
30//!
31//! Sound Mode introduces "Sticky Freshness" - object literals remain subject
32//! to excess property checks as long as they flow through inferred types.
33//!
34//! See `docs/architecture/SOLVER_REFACTORING_PROPOSAL.md` Section 1.3.1
35
36use crate::TypeDatabase;
37use crate::judge::JudgeConfig;
38use crate::subtype::{SubtypeChecker, TypeEnvironment};
39use crate::types::{TypeData, TypeId};
40
41// =============================================================================
42// Sound Mode Diagnostics
43// =============================================================================
44
45/// Sound Mode diagnostic codes.
46///
47/// These use the `TS9xxx` range to distinguish from standard TypeScript errors.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49#[repr(u32)]
50pub enum SoundDiagnosticCode {
51    /// TS9001: Excess property via sticky freshness.
52    /// Object literal has excess properties that would be lost.
53    ExcessPropertyStickyFreshness = 9001,
54
55    /// TS9002: Mutable array covariance.
56    /// Assigning Dog[] to Animal[] allows pushing Cat.
57    MutableArrayCovariance = 9002,
58
59    /// TS9003: Method bivariance.
60    /// Method parameters should be contravariant, not bivariant.
61    MethodBivariance = 9003,
62
63    /// TS9004: Any escape.
64    /// `any` is being used to bypass structural checks.
65    AnyEscape = 9004,
66
67    /// TS9005: Enum-number assignment.
68    /// Enum values should not be freely assignable to/from number.
69    EnumNumberAssignment = 9005,
70
71    /// TS9006: Missing index signature.
72    /// Object being used as a map without proper index signature.
73    MissingIndexSignature = 9006,
74
75    /// TS9007: Unsafe type assertion.
76    /// Type assertion doesn't match actual runtime type.
77    UnsafeTypeAssertion = 9007,
78
79    /// TS9008: Unchecked indexed access.
80    /// Accessing array/object by index without undefined check.
81    UncheckedIndexedAccess = 9008,
82}
83
84impl SoundDiagnosticCode {
85    /// Get the numeric code.
86    pub const fn code(self) -> u32 {
87        self as u32
88    }
89
90    /// Get the diagnostic message template.
91    pub const fn message(self) -> &'static str {
92        match self {
93            Self::ExcessPropertyStickyFreshness => {
94                "Object literal has excess property '{0}' which will be silently lost when assigned to type '{1}'."
95            }
96            Self::MutableArrayCovariance => {
97                "Type '{0}[]' is not safely assignable to type '{1}[]'. Array is mutable and may receive incompatible elements."
98            }
99            Self::MethodBivariance => {
100                "Method parameter type '{0}' is not contravariant with '{1}'. Methods should use strict parameter checking."
101            }
102            Self::AnyEscape => {
103                "Type 'any' is being used to bypass type checking. Consider using a more specific type or 'unknown'."
104            }
105            Self::EnumNumberAssignment => {
106                "Enum '{0}' should not be assigned to/from number without explicit conversion."
107            }
108            Self::MissingIndexSignature => {
109                "Type '{0}' is being used as a map but lacks an index signature. Add '[key: string]: {1}' to the type."
110            }
111            Self::UnsafeTypeAssertion => {
112                "Type assertion from '{0}' to '{1}' may be unsafe. The types do not overlap sufficiently."
113            }
114            Self::UncheckedIndexedAccess => {
115                "Indexed access '{0}[{1}]' may return undefined. Add a null check or enable noUncheckedIndexedAccess."
116            }
117        }
118    }
119}
120
121/// A diagnostic emitted by Sound Mode checking.
122#[derive(Clone, Debug)]
123pub struct SoundDiagnostic {
124    /// The diagnostic code
125    pub code: SoundDiagnosticCode,
126
127    /// Message arguments for formatting
128    pub args: Vec<String>,
129
130    /// Source location (`file_id`, start, end)
131    pub location: Option<(u32, u32, u32)>,
132}
133
134impl SoundDiagnostic {
135    /// Create a new Sound Mode diagnostic.
136    pub const fn new(code: SoundDiagnosticCode) -> Self {
137        Self {
138            code,
139            args: Vec::new(),
140            location: None,
141        }
142    }
143
144    /// Add a message argument.
145    pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
146        self.args.push(arg.into());
147        self
148    }
149
150    /// Set the source location.
151    pub const fn with_location(mut self, file_id: u32, start: u32, end: u32) -> Self {
152        self.location = Some((file_id, start, end));
153        self
154    }
155
156    /// Format the diagnostic message.
157    pub fn format_message(&self) -> String {
158        let mut msg = self.code.message().to_string();
159        for (i, arg) in self.args.iter().enumerate() {
160            let placeholder = format!("{{{i}}}");
161            msg = msg.replace(&placeholder, arg);
162        }
163        msg
164    }
165}
166
167// =============================================================================
168// Sound Lawyer
169// =============================================================================
170
171/// The "Sound Lawyer" - strict type checking that bypasses TypeScript quirks.
172///
173/// While the regular `CompatChecker` (Lawyer) applies TypeScript's unsound rules
174/// for compatibility, the Sound Lawyer enforces proper type theory semantics:
175///
176/// - Function parameters are contravariant (not bivariant)
177/// - Arrays are invariant for mutation (not covariant)
178/// - `any` is only a top type (not also a bottom type)
179/// - Enums are distinct from numbers
180///
181/// ## Usage
182///
183/// ```ignore
184/// let sound_lawyer = SoundLawyer::new(&interner, &env, config);
185///
186/// // Strict assignability check
187/// let result = sound_lawyer.is_assignable(source, target);
188///
189/// // Check with diagnostic collection
190/// let mut diagnostics = vec![];
191/// sound_lawyer.check_assignment(source, target, &mut diagnostics);
192/// ```
193pub struct SoundLawyer<'a> {
194    db: &'a dyn TypeDatabase,
195    env: &'a TypeEnvironment,
196    config: JudgeConfig,
197}
198
199impl<'a> SoundLawyer<'a> {
200    /// Create a new Sound Lawyer.
201    pub fn new(db: &'a dyn TypeDatabase, env: &'a TypeEnvironment, config: JudgeConfig) -> Self {
202        SoundLawyer { db, env, config }
203    }
204
205    /// Check if source is assignable to target under sound typing rules.
206    pub fn is_assignable(&mut self, source: TypeId, target: TypeId) -> bool {
207        // Fast paths
208        if source == target {
209            return true;
210        }
211        if target == TypeId::UNKNOWN {
212            return true;
213        }
214        if source == TypeId::NEVER {
215            return true;
216        }
217
218        // In sound mode, any is ONLY a top type, not a bottom type
219        // any is assignable TO everything, but only any/unknown are assignable FROM any
220        if target == TypeId::ANY {
221            return true;
222        }
223        if source.is_any() {
224            // In sound mode, any can only be assigned to any or unknown
225            return target.is_any_or_unknown();
226        }
227
228        // Error types
229        if source.is_error() || target.is_error() {
230            return source == target;
231        }
232
233        // Use SubtypeChecker with strict settings
234        let mut checker = SubtypeChecker::with_resolver(self.db, self.env);
235        checker.strict_function_types = true; // Always contravariant
236        checker.allow_void_return = false; // Strict void handling
237        checker.allow_bivariant_rest = false; // No bivariant rest params
238        checker.disable_method_bivariance = true; // Methods are also contravariant
239        checker.strict_null_checks = self.config.strict_null_checks;
240        checker.exact_optional_property_types = self.config.exact_optional_property_types;
241        checker.no_unchecked_indexed_access = self.config.no_unchecked_indexed_access;
242
243        checker.is_subtype_of(source, target)
244    }
245
246    /// Check assignment and collect diagnostics.
247    pub fn check_assignment(
248        &mut self,
249        source: TypeId,
250        target: TypeId,
251        diagnostics: &mut Vec<SoundDiagnostic>,
252    ) -> bool {
253        // Check for any escape
254        if self.is_any_escape(source, target) {
255            diagnostics.push(SoundDiagnostic::new(SoundDiagnosticCode::AnyEscape));
256            return false;
257        }
258
259        // Check for mutable array covariance
260        if let Some(diag) = self.check_array_covariance(source, target) {
261            diagnostics.push(diag);
262            return false;
263        }
264
265        // Standard assignability
266        self.is_assignable(source, target)
267    }
268
269    /// Check for "any escape" - using any to bypass type checks.
270    fn is_any_escape(&self, source: TypeId, target: TypeId) -> bool {
271        // any escaping to a non-top type
272        source == TypeId::ANY && target != TypeId::ANY && target != TypeId::UNKNOWN
273    }
274
275    /// Check for unsafe mutable array covariance.
276    fn check_array_covariance(&self, source: TypeId, target: TypeId) -> Option<SoundDiagnostic> {
277        let source_key = self.db.lookup(source)?;
278        let target_key = self.db.lookup(target)?;
279
280        // Check for Array<S> -> Array<T> where S <: T but S != T
281        if let (TypeData::Array(s_elem), TypeData::Array(t_elem)) = (&source_key, &target_key)
282            && s_elem != t_elem
283        {
284            // Different element types - this is potentially unsafe covariance
285            let mut checker = SubtypeChecker::with_resolver(self.db, self.env);
286            checker.strict_function_types = true;
287
288            // Only flag if S <: T (covariant direction)
289            // If neither is subtype, it's already an error
290            if checker.is_subtype_of(*s_elem, *t_elem) && !checker.is_subtype_of(*t_elem, *s_elem) {
291                return Some(
292                    SoundDiagnostic::new(SoundDiagnosticCode::MutableArrayCovariance)
293                        .with_arg(format!("{s_elem:?}"))
294                        .with_arg(format!("{t_elem:?}")),
295                );
296            }
297        }
298
299        None
300    }
301
302    // Sticky freshness handling lives in the checker-side SoundFlowAnalyzer.
303}
304
305// =============================================================================
306// Sound Mode Configuration
307// =============================================================================
308
309/// Configuration for Sound Mode checking.
310#[derive(Clone, Debug, PartialEq, Eq)]
311pub struct SoundModeConfig {
312    /// Enable sticky freshness for excess property checking.
313    pub sticky_freshness: bool,
314
315    /// Disallow any as a bottom type (any -> T).
316    pub strict_any: bool,
317
318    /// Require arrays to be invariant.
319    pub strict_array_covariance: bool,
320
321    /// Require method parameters to be contravariant.
322    pub strict_method_bivariance: bool,
323
324    /// Require explicit enum-to-number conversion.
325    pub strict_enums: bool,
326}
327
328impl Default for SoundModeConfig {
329    fn default() -> Self {
330        Self {
331            sticky_freshness: true,
332            strict_any: true,
333            strict_array_covariance: true,
334            strict_method_bivariance: true,
335            strict_enums: true,
336        }
337    }
338}
339
340impl SoundModeConfig {
341    /// Create a configuration with all sound checks enabled.
342    pub fn all() -> Self {
343        Self::default()
344    }
345
346    /// Create a minimal configuration (for gradual adoption).
347    pub const fn minimal() -> Self {
348        Self {
349            sticky_freshness: true,
350            strict_any: false,
351            strict_array_covariance: false,
352            strict_method_bivariance: false,
353            strict_enums: false,
354        }
355    }
356}
357
358// =============================================================================
359// Tests
360// =============================================================================
361
362#[cfg(test)]
363#[path = "../tests/sound_tests.rs"]
364mod tests;