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;