diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54c18a96..35e11c6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,11 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets --locked -- -D warnings + - name: Fetch pinned TypeScript test fixtures + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-primary-tests --dest target/authority/typescript-7.0.2-tests + - name: Test workspace run: cargo test --workspace --locked diff --git a/CONCEPTS.md b/CONCEPTS.md new file mode 100644 index 00000000..58a83156 --- /dev/null +++ b/CONCEPTS.md @@ -0,0 +1,15 @@ +# Concepts + +## walker duality + +The ES5 generator lowerer's suspension predicates (`contains_yield`, +`contains_await`, `count_yields`, `contains_yield_array`, +`statements_contain_await`) are views of one predicate that must agree +per shape: containment true with count zero inline-clones a live +suspension (miscompile), and a non-zero count on an eval-accepted shape +inflates resume-label arithmetic. Zero in `count_yields` means "provably +clean"; anything not provably clean routes to `eval`, which either splits +or refuses. + +*Avoid:* "three-view predicate", "count/contains symmetry" — say walker +duality. diff --git a/crates/bamts-compiler/src/checker.rs b/crates/bamts-compiler/src/checker.rs index bb0302a8..4e9f0799 100644 --- a/crates/bamts-compiler/src/checker.rs +++ b/crates/bamts-compiler/src/checker.rs @@ -138,6 +138,30 @@ const NOT_ASSIGNABLE_MESSAGE: &str = "Initializer type is not assignable to the /// definitely assigned in the constructor. pub const PROPERTY_NOT_INITIALIZED: DiagnosticCode = DiagnosticCode::new("BAMTS-C028"); /// Diagnostic emitted when an assignment target resolves to a function. +pub const SUPER_BEFORE_THIS: DiagnosticCode = DiagnosticCode::new("BAMTS-C090"); +pub const SUPER_BEFORE_SUPER_PROPERTY: DiagnosticCode = DiagnosticCode::new("BAMTS-C091"); +pub const SUPER_FIELD_VIA_SUPER: DiagnosticCode = DiagnosticCode::new("BAMTS-C092"); +pub const SUPER_STATIC_MEMBER_VIA_SUPER: DiagnosticCode = DiagnosticCode::new("BAMTS-C093"); +pub const BLOCK_SCOPED_USED_BEFORE_DECLARATION: DiagnosticCode = DiagnosticCode::new("BAMTS-C094"); +pub const CLASS_USED_BEFORE_DECLARATION: DiagnosticCode = DiagnosticCode::new("BAMTS-C095"); +pub const ENUM_USED_BEFORE_DECLARATION: DiagnosticCode = DiagnosticCode::new("BAMTS-C096"); +pub const REST_PARAMETER_NOT_LAST: DiagnosticCode = DiagnosticCode::new("BAMTS-C097"); +pub const INTERFACE_NAME_IS_PRIMITIVE: DiagnosticCode = DiagnosticCode::new("BAMTS-C098"); +pub const SUPER_PROPERTY_NOT_METHOD: DiagnosticCode = DiagnosticCode::new("BAMTS-C099"); +pub const PARAMETER_INITIALIZER_IN_SIGNATURE: DiagnosticCode = DiagnosticCode::new("BAMTS-C100"); +pub const DUPLICATE_LABEL: DiagnosticCode = DiagnosticCode::new("BAMTS-C101"); +pub const BREAK_TARGET_NOT_ENCLOSING: DiagnosticCode = DiagnosticCode::new("BAMTS-C102"); +pub const BREAK_TARGET_CROSSES_FUNCTION: DiagnosticCode = DiagnosticCode::new("BAMTS-C103"); +pub(crate) const BREAK_TARGET_CROSSES_FUNCTION_MESSAGE: &str = + "Jump target cannot cross function boundary."; +pub(crate) const BREAK_TARGET_NOT_ENCLOSING_MESSAGE: &str = + "A 'break' statement can only jump to a label of an enclosing statement."; +pub(crate) const PARAMETER_INITIALIZER_IN_SIGNATURE_MESSAGE: &str = + "A parameter initializer is only allowed in a function or constructor implementation."; +pub(crate) const SUPER_PROPERTY_NOT_METHOD_MESSAGE: &str = + "Only public and protected methods of the base class are accessible via the 'super' keyword."; +pub(crate) const REST_PARAMETER_NOT_LAST_MESSAGE: &str = + "A rest parameter must be last in a parameter list."; pub const ASSIGNMENT_TO_FUNCTION: DiagnosticCode = DiagnosticCode::new("BAMTS-C029"); /// Diagnostic emitted when an assignment target resolves to a namespace. pub const ASSIGNMENT_TO_NAMESPACE: DiagnosticCode = DiagnosticCode::new("BAMTS-C030"); @@ -302,6 +326,9 @@ pub(crate) const BARE_SUPER_EXPRESSION_MESSAGE: &str = pub(crate) const SUPER_REFERENCE_NON_DERIVED_MESSAGE: &str = "'super' can only be referenced in a derived class."; pub(crate) const SUPER_CALL_OUTSIDE_CONSTRUCTOR_MESSAGE: &str = "Super calls are not permitted outside constructors or in nested functions inside constructors."; +pub(crate) const SUPER_BEFORE_THIS_MESSAGE: &str = + "'super' must be called before accessing 'this' in the constructor of a derived class."; +pub(crate) const SUPER_BEFORE_SUPER_PROPERTY_MESSAGE: &str = "'super' must be called before accessing a property of 'super' in the constructor of a derived class."; pub(crate) const SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS_MESSAGE: &str = "'super' cannot be referenced in constructor arguments."; const DERIVED_CONSTRUCTOR_MISSING_SUPER_MESSAGE: &str = @@ -2157,16 +2184,21 @@ fn imported_enum_error( #[cfg(test)] mod tests { use super::{ - ARGUMENT_NOT_ASSIGNABLE, BARE_SUPER_EXPRESSION, CANNOT_FIND_NAME, + ARGUMENT_NOT_ASSIGNABLE, BARE_SUPER_EXPRESSION, BLOCK_SCOPED_USED_BEFORE_DECLARATION, + BREAK_TARGET_CROSSES_FUNCTION, BREAK_TARGET_NOT_ENCLOSING, CANNOT_FIND_NAME, CANNOT_FIND_NAME_LIB_GATED, CANNOT_FIND_NAMESPACE, CANNOT_FIND_TYPE, - CONSTRUCTOR_DECORATOR_NOT_SUPPORTED, DERIVED_CONSTRUCTOR_MISSING_SUPER, - DUPLICATE_DECLARATION, EXPRESSION_NOT_CALLABLE, IMPORTED_CONST_ENUM_AMBIGUOUS, - IMPORTED_CONST_ENUM_CYCLE, IMPORTED_CONST_ENUM_NONCONSTANT, INVALID_ASSIGNMENT_TARGET, - MISSING_METHOD_RETURN_TYPE, MIXED_EXPORT_ASSIGNMENT, NAMESPACE_NO_EXPORTED_MEMBER, - PARAMETER_DECORATOR_NOT_SUPPORTED, PARAMETER_PROPERTY_ONLY_IN_CONSTRUCTOR, + CLASS_USED_BEFORE_DECLARATION, CONSTRUCTOR_DECORATOR_NOT_SUPPORTED, + DERIVED_CONSTRUCTOR_MISSING_SUPER, DUPLICATE_DECLARATION, DUPLICATE_LABEL, + ENUM_USED_BEFORE_DECLARATION, EXPRESSION_NOT_CALLABLE, IMPORTED_CONST_ENUM_AMBIGUOUS, + IMPORTED_CONST_ENUM_CYCLE, IMPORTED_CONST_ENUM_NONCONSTANT, INTERFACE_NAME_IS_PRIMITIVE, + INVALID_ASSIGNMENT_TARGET, MISSING_METHOD_RETURN_TYPE, MIXED_EXPORT_ASSIGNMENT, + NAMESPACE_NO_EXPORTED_MEMBER, PARAMETER_DECORATOR_NOT_SUPPORTED, + PARAMETER_INITIALIZER_IN_SIGNATURE, PARAMETER_PROPERTY_ONLY_IN_CONSTRUCTOR, PROPERTY_DOES_NOT_EXIST, ProgramCheckInput, ProgramCheckOptions, PropertyType, - ResolvedModuleEdge, SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS, SUPER_CALL_OUTSIDE_CONSTRUCTOR, - SUPER_REFERENCE_NON_DERIVED, ScopeKind, SymbolKind, TYPE_ALIAS_CIRCULAR, + REST_PARAMETER_NOT_LAST, ResolvedModuleEdge, SUPER_BEFORE_SUPER_PROPERTY, + SUPER_BEFORE_THIS, SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS, SUPER_CALL_OUTSIDE_CONSTRUCTOR, + SUPER_FIELD_VIA_SUPER, SUPER_PROPERTY_NOT_METHOD, SUPER_REFERENCE_NON_DERIVED, + SUPER_STATIC_MEMBER_VIA_SUPER, ScopeKind, SymbolKind, TYPE_ALIAS_CIRCULAR, TYPE_NOT_ASSIGNABLE, TYPE_PARAMETER_CIRCULAR_DEFAULT, Type, TypeId, TypeTable, VALUE_CANNOT_BE_USED_HERE, WITH_STATEMENT_NOT_ALLOWED, check, check_program, check_program_with_options, @@ -5948,6 +5980,675 @@ function check(options: Options = {}) { ); } + fn authority_source(relative: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("target/authority/typescript-7.0.2-tests/tests/cases/compiler") + .join(relative); + std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "authority fixture {} is unavailable: {error}; run \ + `cargo run --locked -p bamts-verification -- source fetch \ + typescript-primary-tests --dest target/authority/typescript-7.0.2-tests` \ + from the repository root first", + path.display() + ) + }) + } + + /// The superAccess es2015 baseline carries one TS2576 (static S1 via + /// super) and two TS2855 (fields S2 and f); the es5 variant's TS2340 + /// flavor is a separate target-conditional slice. + #[test] + fn super_static_member_matches_superaccess_baseline() { + let source = authority_source("superAccess.ts"); + let codes = checker_codes(&check_text(&source)); + assert_eq!( + codes + .iter() + .filter(|c| **c == SUPER_STATIC_MEMBER_VIA_SUPER.as_str()) + .count(), + 1 + ); + assert_eq!( + codes + .iter() + .filter(|c| **c == SUPER_FIELD_VIA_SUPER.as_str()) + .count(), + 2 + ); + } + + /// The blockScopedEnumVariablesUseBeforeDef baseline carries exactly + /// one TS2450: the regular enum in foo1. The const enums (foo2, + /// AfterObject) are inlined at use sites and stay clean. + #[test] + fn enum_used_before_declaration_matches_baseline() { + let source = authority_source("blockScopedEnumVariablesUseBeforeDef.ts"); + let codes = checker_codes(&check_text(&source)); + assert_eq!( + codes + .iter() + .filter(|c| **c == ENUM_USED_BEFORE_DECLARATION.as_str()) + .count(), + 1, + "{codes:?}" + ); + } + + /// TS1114: a label redeclared while it still encloses the current statement. + /// TS1116: a labeled break may only target an enclosing label. The + /// oracles are duplicateLabel2 (one row) and breakTarget6 (one row). + #[test] + fn label_rules_matrix() { + assert_eq!( + checker_codes(&check_text( + "target: while (true) { target: while (true) {} }" + )), + vec![DUPLICATE_LABEL.as_str()] + ); + // A completed sibling label is no longer active and may be reused. + assert_eq!( + checker_codes(&check_text("a: {} a: {}")), + Vec::<&str>::new() + ); + // A nested function owns its labels; reuse across the boundary is + // legal. + assert_eq!( + checker_codes(&check_text("a: {} function f() { a: {} }")), + Vec::<&str>::new() + ); + assert_eq!( + checker_codes(&check_text("while (true) { break target; }")), + vec![BREAK_TARGET_NOT_ENCLOSING.as_str()] + ); + assert_eq!( + checker_codes(&check_text("target: while (true) { break target; }")), + Vec::<&str>::new() + ); + assert_eq!( + checker_codes(&check_text( + "target: while (true) { for (;;) { break target; } }" + )), + Vec::<&str>::new() + ); + // Plain break needs no label context. + assert_eq!( + checker_codes(&check_text("for (;;) { break; }")), + Vec::<&str>::new() + ); + // Authority oracles: one row each. + let duplicate = authority_source("duplicateLabel2.ts"); + assert_eq!( + checker_codes(&check_text(&duplicate)), + vec![DUPLICATE_LABEL.as_str()] + ); + let jump = authority_source("breakTarget6.ts"); + assert_eq!( + checker_codes(&check_text(&jump)), + vec![BREAK_TARGET_NOT_ENCLOSING.as_str()] + ); + // TS1107: the label exists, but in an enclosing function. + let crosses = authority_source("breakTarget5.ts"); + assert_eq!( + checker_codes(&check_text(&crosses)), + vec![BREAK_TARGET_CROSSES_FUNCTION.as_str()] + ); + assert_eq!( + checker_codes(&check_text( + "target: while (true) { function f() { for (;;) { break target; } } }" + )), + vec![BREAK_TARGET_CROSSES_FUNCTION.as_str()] + ); + } + + /// TS2371: only an implementation may carry parameter defaults. The + /// oracle (defaultValueInConstructorOverload1) is exactly one row on a + /// constructor overload signature. + #[test] + fn parameter_initializer_only_in_implementation() { + assert_eq!( + checker_codes(&check_text("function f(x = 1); function f(x?: number) {}")), + vec![PARAMETER_INITIALIZER_IN_SIGNATURE.as_str()] + ); + assert_eq!( + checker_codes(&check_text( + "class C { constructor(x = ''); constructor(x = '') {} }" + )), + vec![PARAMETER_INITIALIZER_IN_SIGNATURE.as_str()] + ); + assert_eq!( + checker_codes(&check_text("function g(x = 1) {}")), + Vec::<&str>::new() + ); + let source = authority_source("defaultValueInConstructorOverload1.ts"); + assert_eq!( + checker_codes(&check_text(&source)), + vec![PARAMETER_INITIALIZER_IN_SIGNATURE.as_str()] + ); + } + + /// The superAccess es5 baseline swaps each TS2855 field row for the + /// single pre-fields rule TS2340; the static row stays TS2576 at both + /// targets. + #[test] + fn super_field_flavor_splits_by_target() { + let source = "class MyBase { + static S1: number = 5; + private S2: string = \"test\"; + f = () => 5; + } + class MyDerived extends MyBase { + foo() { + var l3 = super.S1; + var l4 = super.S2; + var l5 = super.f(); + } + }"; + let es5 = checker_codes(&check_text_with( + source, + ProgramCheckOptions::standard().with_target(Some("es5")), + )); + assert_eq!( + es5.iter() + .filter(|c| **c == SUPER_PROPERTY_NOT_METHOD.as_str()) + .count(), + 2, + "{es5:?}" + ); + assert_eq!( + es5.iter() + .filter(|c| **c == SUPER_FIELD_VIA_SUPER.as_str()) + .count(), + 0, + "{es5:?}" + ); + assert_eq!( + es5.iter() + .filter(|c| **c == SUPER_STATIC_MEMBER_VIA_SUPER.as_str()) + .count(), + 1, + "{es5:?}" + ); + let es2015 = checker_codes(&check_text_with( + source, + ProgramCheckOptions::standard().with_target(Some("es2015")), + )); + assert_eq!( + es2015 + .iter() + .filter(|c| **c == SUPER_FIELD_VIA_SUPER.as_str()) + .count(), + 2, + "{es2015:?}" + ); + assert_eq!( + es2015 + .iter() + .filter(|c| **c == SUPER_PROPERTY_NOT_METHOD.as_str()) + .count(), + 0, + "{es2015:?}" + ); + // The authority file itself: es5 swaps both field rows for TS2340 + // and keeps the static row at TS2576. + let source = authority_source("superAccess.ts"); + let oracle_es5 = checker_codes(&check_text_with( + &source, + ProgramCheckOptions::standard().with_target(Some("es5")), + )); + assert_eq!( + oracle_es5 + .iter() + .filter(|c| **c == SUPER_PROPERTY_NOT_METHOD.as_str()) + .count(), + 2, + "{oracle_es5:?}" + ); + assert_eq!( + oracle_es5 + .iter() + .filter(|c| **c == SUPER_STATIC_MEMBER_VIA_SUPER.as_str()) + .count(), + 1, + "{oracle_es5:?}" + ); + } + + /// TS2427: an interface may not take a primitive type's name. The + /// authority rows cover string, number, boolean, any, never, unknown, + /// symbol, and undefined. Reserved-word names (`void`, `null`) need a + /// parser-relaxation slice before the checker can see them; the + /// implementation bans the full primitive set for the names the parser + /// delivers as identifiers. + #[test] + fn interface_name_cannot_be_primitive() { + for name in [ + "string", + "number", + "boolean", + "any", + "never", + "unknown", + "symbol", + "undefined", + ] { + let source = format!("interface {name} {{}}"); + let codes = checker_codes(&check_text(&source)); + assert_eq!( + codes, + vec![INTERFACE_NAME_IS_PRIMITIVE.as_str()], + "{name}: {codes:?}" + ); + } + // Ordinary names, including intrinsic globals, stay legal. + assert_eq!( + checker_codes(&check_text("interface ArrayLike { length: number }")), + Vec::<&str>::new() + ); + assert_eq!( + checker_codes(&check_text("interface PromiseLike { then(): T }")), + Vec::<&str>::new() + ); + } + + /// TS1014: a rest parameter followed by anything else. Arrows and + /// constructors carry their own binders, so each shape is pinned; a + /// trailing rest parameter stays silent. + #[test] + fn rest_parameter_not_last_matrix() { + let codes = checker_codes(&check_text("function f(...x, y) { }")); + assert_eq!(codes, vec![REST_PARAMETER_NOT_LAST.as_str()]); + let arrow = checker_codes(&check_text("const g = (...x, y) => x;")); + assert_eq!(arrow, vec![REST_PARAMETER_NOT_LAST.as_str()]); + let ctor = checker_codes(&check_text("class C { constructor(...x, y) {} }")); + assert_eq!(ctor, vec![REST_PARAMETER_NOT_LAST.as_str()]); + let method = checker_codes(&check_text("class C { m(...x, y) {} }")); + assert_eq!(method, vec![REST_PARAMETER_NOT_LAST.as_str()]); + assert_eq!( + checker_codes(&check_text("function h(x, ...rest) { }")), + Vec::<&str>::new() + ); + } + + #[test] + fn block_scoped_used_before_declaration_matrix() { + fn codes(text: &str) -> Vec<&'static str> { + checker_codes(&check_text(text)) + } + // let/const/class/enum referenced textually before declaration. + let late_binding = codes( + "return_early(); + function return_early() { + const v = x; + let x = 1; + class C {} + new D(); + class D {} + E.A; + enum E { A } + return v; + }", + ); + assert!(late_binding.contains(&BLOCK_SCOPED_USED_BEFORE_DECLARATION.as_str())); + assert!(late_binding.contains(&CLASS_USED_BEFORE_DECLARATION.as_str())); + assert!( + late_binding.contains(&ENUM_USED_BEFORE_DECLARATION.as_str()), + "{late_binding:?}" + ); + // A reference deferred through a function-like boundary is legal: + // the function runs after the binding initializes, and its body + // resolves once the binding is already in scope. + let deferred = codes( + "function g() { + inner(); + let y = 1; + function inner() { return y; } + const f = () => y; + } + g();", + ); + assert!( + !deferred.contains(&BLOCK_SCOPED_USED_BEFORE_DECLARATION.as_str()), + "{deferred:?}" + ); + // var and function declarations hoist: no error. + let hoisted = codes( + "h(); + var shared = 1; + function h() { return shared; }", + ); + assert!( + !hoisted.contains(&BLOCK_SCOPED_USED_BEFORE_DECLARATION.as_str()), + "{hoisted:?}" + ); + // Use at or after the declaration stays silent. + assert_eq!(codes("let a = 1; a; const b = a; b;"), Vec::<&str>::new()); + } + + /// TS2576: super.S1 where S1 is a base static member. Also pins the + /// superAccess oracle shape: static fires TS2576, instance fields fire + /// TS2855, and a base instance method stays silent. + #[test] + fn super_static_member_via_super_matrix() { + let result = check_text( + "class MyBase { + static S1: number = 5; + f = () => 5; + } + class MyDerived extends MyBase { + foo() { + var l1 = super.S1; + var l3 = super.f; + var l4 = super.toString; + } + }", + ); + let codes = checker_codes(&result); + assert_eq!( + codes + .iter() + .filter(|c| **c == SUPER_STATIC_MEMBER_VIA_SUPER.as_str()) + .count(), + 1, + "{codes:?}" + ); + assert_eq!( + codes + .iter() + .filter(|c| **c == SUPER_FIELD_VIA_SUPER.as_str()) + .count(), + 1, + "{codes:?}" + ); + assert_eq!( + codes + .iter() + .filter(|c| **c == SUPER_BEFORE_SUPER_PROPERTY.as_str()) + .count(), + 0, + "{codes:?}" + ); + } + + /// The authority baseline checkSuperCallBeforeThisAccess.errors.txt + /// carries exactly five TS2855 rows (lines 9, 12, 17, 22, 45 of the + /// directive-stripped source); the count is the pinned oracle. + #[test] + fn super_field_via_super_matches_baseline_count() { + let source = authority_source("checkSuperCallBeforeThisAccess.ts"); + let count = checker_codes(&check_text(&source)) + .into_iter() + .filter(|code| *code == SUPER_FIELD_VIA_SUPER.as_str()) + .count(); + assert_eq!(count, 5); + // The Accessing variants each carry a single TS17009 row; variant 5 + // reaches `this` inside the super() arguments, which evaluate before + // the call completes. + for variant in ["2", "5", "8"] { + let variant_source = + authority_source(&format!("checkSuperCallBeforeThisAccessing{variant}.ts")); + let codes = checker_codes(&check_text(&variant_source)); + assert_eq!( + codes + .iter() + .filter(|code| **code == SUPER_BEFORE_THIS.as_str()) + .count(), + 1, + "variant {}", + variant + ); + } + } + + #[test] + fn super_field_via_super_matrix() { + fn field_codes(text: &str) -> Vec<&'static str> { + let result = check_text(text); + checker_codes(&result) + .into_iter() + .filter(|code| *code == SUPER_FIELD_VIA_SUPER.as_str()) + .collect() + } + // A base field read through super fires even after super() ran. + assert_eq!( + field_codes( + "class A { field = 1; } + class C extends A { + constructor() { super(); super.field; } + }" + ), + vec![SUPER_FIELD_VIA_SUPER.as_str()] + ); + // Base methods and accessor pairs live on the prototype: no fire. + assert_eq!( + field_codes( + "class A { method() {} get pair() { return 1; } set pair(v) {} } + class C extends A { + constructor() { super(); super.method(); super.pair; } + }" + ), + Vec::<&str>::new() + ); + // A getter-only accessor is reachable too. + assert_eq!( + field_codes( + "class A { get only() { return 1; } } + class C extends A { constructor() { super(); super.only; } }" + ), + Vec::<&str>::new() + ); + // The inherited grandparent field still fires, and a name the base + // chain does not declare stays silent. + assert_eq!( + field_codes( + "class A { shared = 1; } + class B extends A {} + class C extends B { constructor() { super(); super.shared; super.absent; } }" + ), + vec![SUPER_FIELD_VIA_SUPER.as_str()] + ); + // No base class, no super property check applies. + assert_eq!( + field_codes("class C { constructor() { super(); } }"), + Vec::<&str>::new() + ); + // A field read before super() reports both TS17011 and TS2855. + let result = check_text( + "class A { field = 1; } + class C extends A { constructor() { super.field; super(); } }", + ); + let codes = checker_codes(&result); + assert!(codes.contains(&SUPER_BEFORE_SUPER_PROPERTY.as_str())); + assert!(codes.contains(&SUPER_FIELD_VIA_SUPER.as_str())); + } + + /// TS17009/TS17011: a derived constructor's `this` and `super.x` + /// accesses before a guaranteed `super()` call, with the oracle's + /// flow shapes: arrows are exempt (deferred `this`), a conditional + /// super() covers only its branch, and loops/try never guarantee. + #[test] + fn super_before_this_flow_matrix() { + fn codes(text: &str) -> Vec<&'static str> { + let result = check_text(text); + let mut found = checker_codes(&result) + .into_iter() + .filter(|code| { + *code == SUPER_BEFORE_THIS.as_str() + || *code == SUPER_BEFORE_SUPER_PROPERTY.as_str() + }) + .collect::>(); + found.sort(); + found + } + + // Straight-line before/after. + assert_eq!( + codes( + "class A extends Object { constructor() { let a = this; super(); let b = this; } }" + ), + vec![SUPER_BEFORE_THIS.as_str()] + ); + // super.x before super reports the property form. + assert_eq!( + codes("class A extends Object { constructor() { let a = super.x; super(); } }"), + vec![SUPER_BEFORE_SUPER_PROPERTY.as_str()] + ); + // Arrows defer their this: no report inside, and none after. + assert_eq!( + codes("class A extends Object { constructor() { let f = () => this; super(); f(); } }"), + Vec::<&str>::new() + ); + // Plain nested functions own their own this. + assert_eq!( + codes( + "class A extends Object { constructor() { let f = function () { return this; }; super(); } }" + ), + Vec::<&str>::new() + ); + // A conditional super() covers its branch only. + assert_eq!( + codes( + "class A extends Object { constructor(c) {\nif (c) { super(); let a = this; }\nelse { let a = this; }\nlet b = this; } }" + ), + vec![SUPER_BEFORE_THIS.as_str(), SUPER_BEFORE_THIS.as_str()] + ); + // Every continuing branch has called super, including when the + // other branch exits before the merge. + assert_eq!( + codes( + "class A extends Object { constructor(c) { if (c) super(); else super(); this; } }" + ), + Vec::<&str>::new() + ); + assert_eq!( + codes( + "class A extends Object { constructor(c) { if (c) return {}; else super(); this; } }" + ), + Vec::<&str>::new() + ); + // A super() inside a loop guarantees nothing after it. + assert_eq!( + codes( + "class A extends Object { constructor(c) { while (c) { super(); break; } let a = this; } }" + ), + vec![SUPER_BEFORE_THIS.as_str()] + ); + // Base constructors never track. + assert_eq!( + codes("class A { constructor() { let a = this; } }"), + Vec::<&str>::new() + ); + // Field initializer and method this are not constructor-flow. + assert_eq!( + codes( + "class A extends Object { x = this; m() { return this; } constructor() { super(); } }" + ), + Vec::<&str>::new() + ); + } + + #[test] + fn pr199_super_flow_joins_every_continuing_path() { + for (body, safe) in [ + ("if (c) { super(); } else { super(); }", true), + ("if (c) { throw 0; } else { super(); }", true), + ("if (c) { super(); } else { throw 0; }", true), + ("if (c) { return { method() {} }; } else { super(); }", true), + ("super(); if (c) {} else {}", true), + ( + "if (c) { if (c) { super(); } else { super(); } } else { super(); }", + true, + ), + ("if (c) { super(); }", false), + ("if (c) {} else { super(); }", false), + ] { + let source = format!( + "class B {{ method() {{}} }} \ + class D extends B {{ constructor(c: boolean) {{ \ + {body} this; super.method(); }} }}" + ); + let mut codes = checker_codes(&check_text(&source)) + .into_iter() + .filter(|code| { + *code == SUPER_BEFORE_THIS.as_str() + || *code == SUPER_BEFORE_SUPER_PROPERTY.as_str() + }) + .collect::>(); + codes.sort_unstable(); + let expected = if safe { + Vec::new() + } else { + vec![ + SUPER_BEFORE_THIS.as_str(), + SUPER_BEFORE_SUPER_PROPERTY.as_str(), + ] + }; + assert_eq!(codes, expected, "{body}"); + } + } + + #[test] + fn pr199_label_frames_and_nearest_targets() { + let cases = [ + ("L: {} L: {}", None), + ("L: { L: {} }", Some(DUPLICATE_LABEL.as_str())), + ("L: { function f() { L: { break L; } } break L; }", None), + ("L: { const f = () => { L: { break L; } }; break L; }", None), + ( + "L: { class C { constructor() { L: { break L; } } } break L; }", + None, + ), + ( + "L: { function f() { break L; } break L; }", + Some(BREAK_TARGET_CROSSES_FUNCTION.as_str()), + ), + ( + "L: { const f = () => { break L; }; break L; }", + Some(BREAK_TARGET_CROSSES_FUNCTION.as_str()), + ), + ( + "L: { class C { constructor() { break L; } } break L; }", + Some(BREAK_TARGET_CROSSES_FUNCTION.as_str()), + ), + ( + "L: { class C { static { break L; } } break L; }", + Some(BREAK_TARGET_CROSSES_FUNCTION.as_str()), + ), + ("const f = () => { local: {} }; local: {}", None), + ("class C { constructor() { local: {} } } local: {}", None), + ("class C { static { local: {} } } local: {}", None), + ("L: {} break L;", Some(BREAK_TARGET_NOT_ENCLOSING.as_str())), + ( + "while (true) { break missing; }", + Some(BREAK_TARGET_NOT_ENCLOSING.as_str()), + ), + ]; + for (source, expected) in cases { + let codes = checker_codes(&check_text(source)) + .into_iter() + .filter(|code| { + *code == DUPLICATE_LABEL.as_str() + || *code == BREAK_TARGET_NOT_ENCLOSING.as_str() + || *code == BREAK_TARGET_CROSSES_FUNCTION.as_str() + }) + .collect::>(); + assert_eq!(codes, expected.into_iter().collect::>(), "{source}"); + } + } + + #[test] + fn pr199_missing_authority_fixture_explains_the_prerequisite() { + let panic = std::panic::catch_unwind(|| { + authority_source("__pr199_fixture_that_must_not_exist__.ts") + }) + .expect_err("a missing authority fixture must fail rather than skip"); + let message = panic.downcast_ref::().expect("formatted panic"); + assert!(message.contains("__pr199_fixture_that_must_not_exist__.ts")); + assert!(message.contains("source fetch typescript-primary-tests")); + assert!(message.contains("--dest target/authority/typescript-7.0.2-tests")); + } + #[test] fn super_call_context_matrix() { const SUPER_CODES: [&str; 4] = [ diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 07e9baff..24707805 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -28,32 +28,39 @@ use super::{ ABSTRACT_CONSTRUCTOR, ACCESSOR_THIS_PARAMETER, AMBIENT_IMPLEMENTATION, ARGUMENT_COUNT_MISMATCH, ARGUMENT_NOT_ASSIGNABLE, ASSIGNMENT_TO_CONST, ASSIGNMENT_TO_FUNCTION, ASSIGNMENT_TO_NAMESPACE, ASSIGNMENT_TO_READONLY, AWAIT_USING_DECLARATION_IN_FOR_IN, BARE_SUPER_EXPRESSION, - CANNOT_FIND_NAME, CANNOT_FIND_NAME_LIB_GATED, CANNOT_FIND_NAMESPACE, CANNOT_FIND_TYPE, + BLOCK_SCOPED_USED_BEFORE_DECLARATION, BREAK_TARGET_CROSSES_FUNCTION, + BREAK_TARGET_NOT_ENCLOSING, CANNOT_FIND_NAME, CANNOT_FIND_NAME_LIB_GATED, + CANNOT_FIND_NAMESPACE, CANNOT_FIND_TYPE, CLASS_USED_BEFORE_DECLARATION, CONSTRUCTOR_DECORATOR_NOT_SUPPORTED, CONSTRUCTOR_TYPE_PARAMETERS, DECLARATION_CONFLICTS_WITH_BUILTIN_GLOBAL, DERIVED_CONSTRUCTOR_MISSING_SUPER, - DUPLICATE_DECLARATION, EXCESS_PROPERTY, EXPRESSION_NOT_CALLABLE, EXPRESSION_NOT_CONSTRUCTABLE, - FOR_IN_LEFT_HAND_SIDE_INVALID, FOR_OF_ITERABLE_REQUIRED, - FUNCTION_DECLARATION_IN_BLOCK_ES5_STRICT, FUNCTION_IMPLEMENTATION_WRONG_NAME, - FUNCTION_OVERLOAD_MISSING_IMPLEMENTATION, GET_ACCESSOR_NO_RETURN, GET_ACCESSOR_PARAMETERS, - IMPORT_CONFLICTS_WITH_LOCAL, INVALID_ASSIGNMENT_TARGET, INVALID_INDEXED_ACCESS_KEY, + DUPLICATE_DECLARATION, DUPLICATE_LABEL, ENUM_USED_BEFORE_DECLARATION, EXCESS_PROPERTY, + EXPRESSION_NOT_CALLABLE, EXPRESSION_NOT_CONSTRUCTABLE, FOR_IN_LEFT_HAND_SIDE_INVALID, + FOR_OF_ITERABLE_REQUIRED, FUNCTION_DECLARATION_IN_BLOCK_ES5_STRICT, + FUNCTION_IMPLEMENTATION_WRONG_NAME, FUNCTION_OVERLOAD_MISSING_IMPLEMENTATION, + GET_ACCESSOR_NO_RETURN, GET_ACCESSOR_PARAMETERS, IMPORT_CONFLICTS_WITH_LOCAL, + INTERFACE_NAME_IS_PRIMITIVE, INVALID_ASSIGNMENT_TARGET, INVALID_INDEXED_ACCESS_KEY, MEMBER_NOT_ACCESSIBLE, MISSING_METHOD_RETURN_TYPE, MIXED_EXPORT_ASSIGNMENT, NAMESPACE_NO_EXPORTED_MEMBER, NEW_TARGET_OUTSIDE_FUNCTION, NON_VOID_FUNCTION_MUST_RETURN, - PARAMETER_DECORATOR_NOT_SUPPORTED, PARAMETER_PROPERTY_ONLY_IN_CONSTRUCTOR, - PROPERTY_DOES_NOT_EXIST, PROPERTY_NOT_INITIALIZED, SET_ACCESSOR_PARAMETER_INITIALIZER, + PARAMETER_DECORATOR_NOT_SUPPORTED, PARAMETER_INITIALIZER_IN_SIGNATURE, + PARAMETER_PROPERTY_ONLY_IN_CONSTRUCTOR, PROPERTY_DOES_NOT_EXIST, PROPERTY_NOT_INITIALIZED, + REST_PARAMETER_NOT_LAST, SET_ACCESSOR_PARAMETER_INITIALIZER, STATEMENT_NOT_ALLOWED_IN_AMBIENT_CONTEXT, STRICT_NULL_MEMBER_ACCESS, - SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS, SUPER_CALL_OUTSIDE_CONSTRUCTOR, - SUPER_REFERENCE_NON_DERIVED, TYPE_ALIAS_CIRCULAR, TYPE_NESTING_TOO_DEEP, TYPE_NOT_ASSIGNABLE, - TYPE_PARAMETER_CIRCULAR_DEFAULT, UNUSED_EXPECT_ERROR, USED_BEFORE_ASSIGNED, - USING_DECLARATION_BINDING_PATTERN, USING_DECLARATION_IN_FOR_IN, - USING_DECLARATION_MISSING_INITIALIZER, VALUE_CANNOT_BE_USED_HERE, WITH_STATEMENT_NOT_ALLOWED, + SUPER_BEFORE_SUPER_PROPERTY, SUPER_BEFORE_THIS, SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS, + SUPER_CALL_OUTSIDE_CONSTRUCTOR, SUPER_FIELD_VIA_SUPER, SUPER_PROPERTY_NOT_METHOD, + SUPER_REFERENCE_NON_DERIVED, SUPER_STATIC_MEMBER_VIA_SUPER, TYPE_ALIAS_CIRCULAR, + TYPE_NESTING_TOO_DEEP, TYPE_NOT_ASSIGNABLE, TYPE_PARAMETER_CIRCULAR_DEFAULT, + UNUSED_EXPECT_ERROR, USED_BEFORE_ASSIGNED, USING_DECLARATION_BINDING_PATTERN, + USING_DECLARATION_IN_FOR_IN, USING_DECLARATION_MISSING_INITIALIZER, VALUE_CANNOT_BE_USED_HERE, + WITH_STATEMENT_NOT_ALLOWED, }; use super::{ ABSTRACT_CONSTRUCTOR_MESSAGE, ACCESSOR_THIS_PARAMETER_MESSAGE, AMBIENT_IMPLEMENTATION_MESSAGE, ARGUMENT_COUNT_MISMATCH_MESSAGE, ARGUMENT_NOT_ASSIGNABLE_MESSAGE, ASSIGNMENT_TO_CONST_MESSAGE, ASSIGNMENT_TO_FUNCTION_MESSAGE, ASSIGNMENT_TO_NAMESPACE_MESSAGE, ASSIGNMENT_TO_READONLY_MESSAGE, AWAIT_USING_DECLARATION_IN_FOR_IN_MESSAGE, - BARE_SUPER_EXPRESSION_MESSAGE, CANNOT_FIND_NAME_LIB_GATED_MESSAGE, CANNOT_FIND_NAME_MESSAGE, - CANNOT_FIND_NAMESPACE_MESSAGE, CANNOT_FIND_TYPE_MESSAGE, + BARE_SUPER_EXPRESSION_MESSAGE, BREAK_TARGET_CROSSES_FUNCTION_MESSAGE, + BREAK_TARGET_NOT_ENCLOSING_MESSAGE, CANNOT_FIND_NAME_LIB_GATED_MESSAGE, + CANNOT_FIND_NAME_MESSAGE, CANNOT_FIND_NAMESPACE_MESSAGE, CANNOT_FIND_TYPE_MESSAGE, CONSTRUCTOR_DECORATOR_NOT_SUPPORTED_MESSAGE, CONSTRUCTOR_TYPE_PARAMETERS_MESSAGE, DERIVED_CONSTRUCTOR_MISSING_SUPER_MESSAGE, DUPLICATE_MESSAGE, EXCESS_PROPERTY_MESSAGE, EXPRESSION_NOT_CALLABLE_MESSAGE, EXPRESSION_NOT_CONSTRUCTABLE_MESSAGE, @@ -65,16 +72,19 @@ use super::{ MEMBER_NOT_ACCESSIBLE_MESSAGE, MISSING_METHOD_RETURN_TYPE_MESSAGE, MIXED_EXPORT_ASSIGNMENT_MESSAGE, NEW_TARGET_OUTSIDE_FUNCTION_MESSAGE, NON_VOID_FUNCTION_MUST_RETURN_MESSAGE, NOT_ASSIGNABLE_MESSAGE, - PARAMETER_DECORATOR_NOT_SUPPORTED_MESSAGE, PARAMETER_PROPERTY_ONLY_IN_CONSTRUCTOR_MESSAGE, - PROPERTY_DOES_NOT_EXIST_MESSAGE, PROPERTY_NOT_INITIALIZED_MESSAGE, + PARAMETER_DECORATOR_NOT_SUPPORTED_MESSAGE, PARAMETER_INITIALIZER_IN_SIGNATURE_MESSAGE, + PARAMETER_PROPERTY_ONLY_IN_CONSTRUCTOR_MESSAGE, PROPERTY_DOES_NOT_EXIST_MESSAGE, + PROPERTY_NOT_INITIALIZED_MESSAGE, REST_PARAMETER_NOT_LAST_MESSAGE, SET_ACCESSOR_PARAMETER_INITIALIZER_MESSAGE, STATEMENT_NOT_ALLOWED_IN_AMBIENT_CONTEXT_MESSAGE, - STRICT_NULL_MEMBER_ACCESS_MESSAGE, SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS_MESSAGE, - SUPER_CALL_OUTSIDE_CONSTRUCTOR_MESSAGE, SUPER_REFERENCE_NON_DERIVED_MESSAGE, - TYPE_ALIAS_CIRCULAR_MESSAGE, TYPE_NESTING_TOO_DEEP_MESSAGE, - TYPE_PARAMETER_CIRCULAR_DEFAULT_MESSAGE, UNUSED_EXPECT_ERROR_MESSAGE, - USED_BEFORE_ASSIGNED_MESSAGE, USING_DECLARATION_BINDING_PATTERN_MESSAGE, - USING_DECLARATION_IN_FOR_IN_MESSAGE, USING_DECLARATION_MISSING_INITIALIZER_MESSAGE, - VALUE_CANNOT_BE_USED_HERE_MESSAGE, WITH_STATEMENT_NOT_ALLOWED_MESSAGE, + STRICT_NULL_MEMBER_ACCESS_MESSAGE, SUPER_BEFORE_SUPER_PROPERTY_MESSAGE, + SUPER_BEFORE_THIS_MESSAGE, SUPER_CALL_IN_CONSTRUCTOR_ARGUMENTS_MESSAGE, + SUPER_CALL_OUTSIDE_CONSTRUCTOR_MESSAGE, SUPER_PROPERTY_NOT_METHOD_MESSAGE, + SUPER_REFERENCE_NON_DERIVED_MESSAGE, TYPE_ALIAS_CIRCULAR_MESSAGE, + TYPE_NESTING_TOO_DEEP_MESSAGE, TYPE_PARAMETER_CIRCULAR_DEFAULT_MESSAGE, + UNUSED_EXPECT_ERROR_MESSAGE, USED_BEFORE_ASSIGNED_MESSAGE, + USING_DECLARATION_BINDING_PATTERN_MESSAGE, USING_DECLARATION_IN_FOR_IN_MESSAGE, + USING_DECLARATION_MISSING_INITIALIZER_MESSAGE, VALUE_CANNOT_BE_USED_HERE_MESSAGE, + WITH_STATEMENT_NOT_ALLOWED_MESSAGE, }; use crate::diagnostic::{Diagnostic, DiagnosticCode}; use crate::enum_plan::{self, EnumDeclarationBinding, EnumFacts}; @@ -229,6 +239,26 @@ pub enum SymbolKind { Namespace, } +/// Names an interface may not take: they already denote primitive +/// types, so a same-named interface would shadow them everywhere. +fn is_primitive_type_name(name: &str) -> bool { + matches!( + name, + "any" + | "unknown" + | "never" + | "void" + | "undefined" + | "null" + | "string" + | "number" + | "boolean" + | "bigint" + | "symbol" + | "object" + ) +} + impl SymbolKind { const fn occupies_value(self) -> bool { matches!( @@ -369,6 +399,12 @@ pub struct PropertyType { /// members default to `false`; properties that legitimately propagate /// through spread keep the flag explicitly. spreadable: bool, + /// Whether this property is backed by a get/set accessor pair. A + /// getter+setter pair reports `is_method == false` and + /// `getter_only == false`, the same shape as a field; this marker is + /// what tells them apart (`super.x` reaches accessors on the + /// prototype but never a field). + accessor: bool, } impl PropertyType { @@ -385,6 +421,7 @@ impl PropertyType { declaring_types: Vec::new(), is_method: false, spreadable: false, + accessor: false, } } @@ -438,6 +475,13 @@ impl PropertyType { self } + /// Marks this property as accessor-backed (a get or get/set pair). + #[must_use] + pub fn with_accessor(mut self, accessor: bool) -> Self { + self.accessor = accessor; + self + } + #[must_use] pub fn with_spreadable(mut self, spreadable: bool) -> Self { self.spreadable = spreadable; @@ -474,6 +518,13 @@ impl PropertyType { self.getter_only } + /// Whether a get/set accessor (not a field or method) backs this + /// property. + #[must_use] + pub const fn accessor(&self) -> bool { + self.accessor + } + #[must_use] pub const fn type_id(&self) -> TypeId { self.type_id @@ -507,6 +558,7 @@ impl PartialEq for PropertyType { && self.declaring_types == other.declaring_types && self.is_method == other.is_method && self.spreadable == other.spreadable + && self.accessor == other.accessor } } @@ -524,6 +576,7 @@ impl std::hash::Hash for PropertyType { self.declaring_types.hash(state); self.is_method.hash(state); self.spreadable.hash(state); + self.accessor.hash(state); } } @@ -2261,6 +2314,25 @@ impl TypeTable { } /// Ensures and returns the finite shallow view for one applied root. + /// Instance members of a class symbol's current template, without + /// instantiation. Membership kind (field, method, accessor) never + /// depends on type arguments, so callers that only ask kind + /// questions can skip the applied-view machinery. + #[must_use] + pub fn class_template_properties(&self, symbol: SymbolId) -> &[PropertyType] { + let Some(raw) = self + .classes + .get(&symbol) + .and_then(|metadata| metadata.template.as_ref().map(|template| template.raw)) + else { + return &[]; + }; + match self.get(raw) { + Type::ObjectType(object) => object.properties(), + _ => &[], + } + } + pub fn prepare_applied_class_view(&mut self, type_id: TypeId) -> Option { self.materialize_applied_class_view(type_id); self.applied_class_view(type_id) @@ -3877,7 +3949,7 @@ impl TypeTable { target.intersection_ordered(members) } Type::ObjectType(object) => { - let properties = object + let properties: Vec = object .properties .into_iter() .map(|property| { @@ -3901,6 +3973,7 @@ impl TypeTable { .with_accessibility(property.access(), declaring_class) .with_declaring_types(declaring_types) .with_method(property.is_method) + .with_accessor(property.accessor) .with_spreadable(property.spreadable) }) .collect(); @@ -4740,6 +4813,16 @@ impl SemanticModel { /// derived class constructor body. Every other position (a base-class /// constructor, constructor parameter initializers, or any non-constructor /// function) maps to a distinct TypeScript diagnostic. +/// Whether the current position sits inside a derived constructor body +/// whose `super()` call has been guaranteed on every path so far, or in +/// a context (nested function-like, non-derived body) where the +/// before-super rules do not apply. +#[derive(Clone, Copy, PartialEq)] +enum SuperFlow { + Tracking { called: bool }, + Suspended, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SuperCallContext { DerivedConstructor, @@ -4971,6 +5054,11 @@ pub(crate) struct Binder<'src> { super_call_contexts: Vec, /// Legal `super()` presence for active derived constructor bodies. derived_constructor_super_presence: Vec, + super_flow: SuperFlow, + /// Whether a super() call currently being resolved sits in statement + /// position (guarantees the flow) or inside a larger expression (a + /// ternary arm, an object member - guarantees nothing). + super_call_guarantees: bool, /// Whether each lexically enclosing class has a base class, innermost last. class_derived_stack: Vec, /// Own readonly storage properties for each lexically enclosing class. @@ -4985,6 +5073,16 @@ pub(crate) struct Binder<'src> { /// body is a function declaration, function expression, or constructor. /// `false` when it is a method, getter, setter, or static block. new_target_contexts: Vec, + /// Label names declared in the current function, for TS1114. Nested + /// function-likes record a mark so their labels never leak outward. + label_declarations: Vec, + label_scope_marks: Vec, + /// Labels of enclosing labeled statements, tagged with the function + /// frame that declared them. A `break L` is only legal when `L` is on + /// this stack in the current frame; a hit in an outer frame is TS1107. + label_ancestors: Vec<(String, usize)>, + /// Current function frame for label ancestry. + label_frame: usize, /// Enclosing `declare` contexts. `true` when the current statement is /// directly under a `declare` keyword. ambient_stack: Vec, @@ -5130,10 +5228,16 @@ impl<'src> Binder<'src> { reassigned_flow_roots_stack: Vec::new(), super_call_contexts: Vec::new(), derived_constructor_super_presence: Vec::new(), + super_flow: SuperFlow::Suspended, + super_call_guarantees: true, class_derived_stack: Vec::new(), constructor_writable_readonly_properties: Vec::new(), readonly_assignment_targets: HashSet::new(), new_target_contexts: Vec::new(), + label_declarations: Vec::new(), + label_scope_marks: Vec::new(), + label_ancestors: Vec::new(), + label_frame: 0, flow_facts: FlowFacts::new(), flow: FlowNodeId::ROOT, ambient_stack: Vec::new(), @@ -7496,8 +7600,16 @@ impl<'src> Binder<'src> { scope: ScopeId, declaration: NodeId, ) { + let name = self.identifier_text(&interface.name); + if is_primitive_type_name(&name) { + self.emit_with_message( + INTERFACE_NAME_IS_PRIMITIVE, + interface.name.range(), + format!("Interface name cannot be '{name}'."), + ); + } let id = self.declare( - &self.identifier_text(&interface.name), + &name, SymbolKind::Interface, scope, declaration, @@ -8760,23 +8872,55 @@ impl<'src> Binder<'src> { self.resolve_statements(&block.data().statements, child); } Statement::Expression(statement) => { + let positional = matches!( + statement.expression.data(), + Expression::Call(call) if matches!(call.callee.data(), Expression::Super) + ); + let outer_guarantees = self.super_call_guarantees; + self.super_call_guarantees = positional; self.resolve_expr(&statement.expression, scope); + self.super_call_guarantees = outer_guarantees; self.type_of_expr(&statement.expression, scope); } Statement::If(statement) => { self.resolve_expr(&statement.test, scope); self.type_of_expr(&statement.test, scope); let parent = self.flow; + let entry_super_flow = self.super_flow; let truthy = self.guards_for(&statement.test, false); let falsy = self.guards_for(&statement.test, true); let then_end = self.in_branch(parent, &truthy, |binder| { binder.resolve_statement(&statement.consequent, scope); }); + let then_super = self.super_flow; + self.super_flow = entry_super_flow; let else_end = self.in_branch(parent, &falsy, |binder| { if let Some(alternate) = &statement.alternate { binder.resolve_statement(alternate, scope); } }); + let else_super = self.super_flow; + // super() guarantees past the if only when every + // fall-through path has called it: an always-exiting + // branch imposes nothing, and a missing alternate keeps + // the entry state for its implicit fall-through. + let then_exits = Self::statement_always_exits(statement.consequent.data()); + let else_exits = statement + .alternate + .as_ref() + .is_some_and(|alt| Self::statement_always_exits(alt.data())); + self.super_flow = match (entry_super_flow, then_super, else_super) { + (SuperFlow::Tracking { called: entry }, then_flow, else_flow) => { + let then_ok = + then_exits || matches!(then_flow, SuperFlow::Tracking { called: true }); + let else_ok = + else_exits || matches!(else_flow, SuperFlow::Tracking { called: true }); + SuperFlow::Tracking { + called: entry || (then_ok && else_ok), + } + } + (suspended, _, _) => suspended, + }; // Only branches control can fall out of reach the merge, so an // `if (guard) { return; }` leaves the negated guard in force after it. let mut live = Vec::with_capacity(2); @@ -8806,12 +8950,15 @@ impl<'src> Binder<'src> { for case in &statement.cases { self.publish_statement_class_shapes(&case.data().consequent, child); } + let entry_super_flow = self.super_flow; for case in &statement.cases { self.check_bound_statements(&case.data().consequent, child); + self.super_flow = entry_super_flow; } } Statement::For(for_statement) => { let child = self.new_scope(ScopeKind::For, Some(scope)); + let entry_super_flow = self.super_flow; if let Some(initializer) = &for_statement.initializer { self.resolve_for_initializer(initializer, child); } @@ -8839,6 +8986,9 @@ impl<'src> Binder<'src> { } else { self.flow = body_end; } + if let SuperFlow::Tracking { .. } = self.super_flow { + self.super_flow = entry_super_flow; + } } Statement::ForIn(for_statement) => { let child = self.new_scope(ScopeKind::For, Some(scope)); @@ -8907,6 +9057,7 @@ impl<'src> Binder<'src> { self.resolve_expr(&statement.test, scope); self.type_of_expr(&statement.test, scope); let parent = self.flow; + let entry_super_flow = self.super_flow; let truthy = self.guards_for(&statement.test, false); let falsy = self.guards_for(&statement.test, true); let body_end = self.in_branch(parent, &truthy, |binder| { @@ -8915,13 +9066,21 @@ impl<'src> Binder<'src> { let skipped = self.branch_guarded(parent, &falsy); let body_exit = self.branch_guarded(body_end, &falsy); self.join_flow(parent, &[skipped, body_exit]); + // A super() inside the loop body ran zero times on the + // skip path, so it guarantees nothing afterwards. + self.super_flow = entry_super_flow; } Statement::DoWhile(statement) => { + let entry_super_flow = self.super_flow; self.resolve_statement(&statement.body, scope); + if let SuperFlow::Tracking { .. } = self.super_flow { + self.super_flow = entry_super_flow; + } self.resolve_expr(&statement.test, scope); self.type_of_expr(&statement.test, scope); } Statement::Try(statement) => { + let entry_super_flow = self.super_flow; let block = &statement.block; let try_scope = self.new_scope(ScopeKind::Block, Some(scope)); self.bind_statements(&block.data().statements, try_scope); @@ -8940,6 +9099,11 @@ impl<'src> Binder<'src> { self.bind_statements(&finalizer.data().statements, finally_scope); self.resolve_statements(&finalizer.data().statements, finally_scope); } + // A throw before the super() call reaches the handler, + // so calls inside the try block guarantee nothing after. + if let SuperFlow::Tracking { .. } = self.super_flow { + self.super_flow = entry_super_flow; + } } Statement::With(with_statement) => { let forbidden = self.is_typescript() || self.scopes[scope.0 as usize].is_strict(); @@ -8959,7 +9123,55 @@ impl<'src> Binder<'src> { }; self.resolve_statement(&with_statement.body, body_scope); } - Statement::Labeled(statement) => self.resolve_statement(&statement.body, scope), + Statement::Labeled(statement) => { + let label = self.identifier_text(&statement.label).into_owned(); + // TS1114 concerns active labels, not earlier sibling statements. + let label_mark = self.label_declarations.len(); + let function_labels_start = self.label_scope_marks.last().copied().unwrap_or(0); + if self.label_declarations[function_labels_start..].contains(&label) { + self.emit_with_message( + DUPLICATE_LABEL, + statement.label.range(), + format!("Duplicate label '{label}'."), + ); + } else { + self.label_declarations.push(label.clone()); + } + self.label_ancestors.push((label, self.label_frame)); + self.resolve_statement(&statement.body, scope); + self.label_ancestors.pop(); + self.label_declarations.truncate(label_mark); + } + Statement::Break(jump) => { + // Retain outer frames to distinguish TS1107 from TS1116, + // but select the nearest enclosing label first. + if let Some(label_node) = &jump.label { + let label = self.identifier_text(label_node); + let hit = self + .label_ancestors + .iter() + .rev() + .find(|(ancestor, _)| *ancestor == label.as_ref()); + match hit { + Some((_, frame)) if *frame == self.label_frame => {} + // TS1107: the label encloses, but a function + // boundary sits between it and the break. + Some(_) => self.emit( + BREAK_TARGET_CROSSES_FUNCTION, + label_node.range(), + BREAK_TARGET_CROSSES_FUNCTION_MESSAGE, + ), + // TS1116: no declaration of the label encloses the + // break at all. + None => self.emit( + BREAK_TARGET_NOT_ENCLOSING, + label_node.range(), + BREAK_TARGET_NOT_ENCLOSING_MESSAGE, + ), + } + } + } + Statement::Continue(_) => {} Statement::ImportEquals(_) => {} Statement::Return(return_statement) => { let context = self.return_contexts.last().copied(); @@ -9518,6 +9730,20 @@ impl<'src> Binder<'src> { self.types.generator_return_type(annotation) } + fn push_label_scope(&mut self) { + self.label_scope_marks.push(self.label_declarations.len()); + self.label_frame += 1; + } + + fn pop_label_scope(&mut self) { + let mark = self + .label_scope_marks + .pop() + .expect("label scope must be entered before it is left"); + self.label_declarations.truncate(mark); + self.label_frame -= 1; + } + fn resolve_function( &mut self, function: &'src FunctionLike, @@ -9533,6 +9759,14 @@ impl<'src> Binder<'src> { self.super_call_contexts .push(SuperCallContext::NonConstructor); self.super_member_homes.push(member_home); + // A nested function-like's `this` is either captured-and-deferred + // (arrows) or its own (functions), so the before-super rules do + // not apply inside it. + let outer_super_flow = self.super_flow; + self.super_flow = SuperFlow::Suspended; + let outer_guarantees = self.super_call_guarantees; + self.super_call_guarantees = true; + self.push_label_scope(); self.bind_implicit_function_values(&function.parameters, scope); let function_symbol = function.name.as_ref().map(|name| { let symbol_scope = if is_declaration { parent } else { scope }; @@ -9546,6 +9780,20 @@ impl<'src> Binder<'src> { }); self.bind_type_parameters(function.type_parameters.as_ref(), scope); let this_type = self.this_parameter_type(&function.parameters, scope, this_type); + self.check_rest_parameter_last(&function.parameters); + // TS2371: only an implementation may carry parameter defaults; an + // overload signature has no body to receive them. + if function.body.is_none() { + for parameter in &function.parameters { + if parameter.data().initializer.is_some() { + self.emit( + PARAMETER_INITIALIZER_IN_SIGNATURE, + parameter.range(), + PARAMETER_INITIALIZER_IN_SIGNATURE_MESSAGE, + ); + } + } + } for parameter in &function.parameters { if self.is_this_parameter(parameter) { continue; @@ -9652,6 +9900,9 @@ impl<'src> Binder<'src> { let popped_context = self.super_call_contexts.pop(); debug_assert_eq!(popped_context, Some(SuperCallContext::NonConstructor)); let popped_home = self.super_member_homes.pop(); + self.super_flow = outer_super_flow; + self.super_call_guarantees = outer_guarantees; + self.pop_label_scope(); debug_assert_eq!(popped_home, Some(member_home)); } @@ -9866,6 +10117,25 @@ impl<'src> Binder<'src> { } } + /// TS1014: a rest parameter must be the last parameter. Constructor + /// and arrow parameter lists route through their own binders, so each + /// calls this beside its loop. + fn check_rest_parameter_last(&mut self, parameters: &[crate::syntax::ParameterNode]) { + for (index, parameter) in parameters.iter().enumerate() { + if matches!( + parameter.data().binding.data(), + crate::syntax::BindingPattern::Rest(_) + ) && index + 1 < parameters.len() + { + self.emit( + REST_PARAMETER_NOT_LAST, + parameter.range(), + REST_PARAMETER_NOT_LAST_MESSAGE, + ); + } + } + } + fn resolve_class(&mut self, class: &'src ClassDeclaration, parent: ScopeId) { let ambient = class.modifiers.is_declare || self.ambient_stack.last().copied().unwrap_or(false); @@ -10541,140 +10811,148 @@ impl<'src> Binder<'src> { }); continue; } - let (name, type_id, optional, readonly, getter_only, access, is_method) = match member - .data() - { - ClassMember::Property(property) if side.includes(property.modifiers.is_static) => { - let Some(name) = self.property_key(&property.name) else { - continue; - }; - let type_id = self.class_property_type( - property.type_annotation.as_ref(), - property.initializer.as_deref(), - &property.modifiers, - scope, - false, - ); - ( - name, - type_id, - property.optional, - property.modifiers.is_readonly, - false, - property - .modifiers - .accessibility - .unwrap_or(Accessibility::Public), - false, - ) - } - ClassMember::AutoAccessor(accessor) - if side.includes(accessor.modifiers.is_static) => - { - let Some(name) = self.property_key(&accessor.name) else { - continue; - }; - let type_id = self.class_property_type( - accessor.type_annotation.as_ref(), - accessor.initializer.as_deref(), - &accessor.modifiers, - scope, - false, - ); - ( - name, - type_id, - false, - accessor.modifiers.is_readonly, - false, - accessor - .modifiers - .accessibility - .unwrap_or(Accessibility::Public), - false, - ) - } - ClassMember::Method(method) if side.includes(method.modifiers.is_static) => { - match method.modifier { - PropertyModifier::None => { - let Some(name) = self.property_key(&method.name) else { - continue; - }; - if name == "constructor" { - continue; - } - let is_overload_signature = - method.function.body.is_none() && !method.modifiers.is_abstract; - if is_overload_signature { - overload_state.insert(name.clone(), true); - } else if overload_state.get(&name).copied().unwrap_or(false) { - overload_state.insert(name.clone(), false); - continue; - } - let signature_scope = self.class_method_signature_scope( - member.id(), - &method.function, - scope, - ); - let type_id = self - .type_of_function_like_in_scope(&method.function, signature_scope); - ( - name, - type_id, - method.optional, - false, - false, - method - .modifiers - .accessibility - .unwrap_or(Accessibility::Public), - true, - ) - } - PropertyModifier::Get => { - let Some(name) = self.property_key(&method.name) else { - continue; - }; - let type_id = match &method.function.return_type { - Some(annotation) => { - self.resolve_type(&annotation.data().type_node, scope) + let (name, type_id, optional, readonly, getter_only, access, is_method, accessor) = + match member.data() { + ClassMember::Property(property) + if side.includes(property.modifiers.is_static) => + { + let Some(name) = self.property_key(&property.name) else { + continue; + }; + let type_id = self.class_property_type( + property.type_annotation.as_ref(), + property.initializer.as_deref(), + &property.modifiers, + scope, + false, + ); + ( + name, + type_id, + property.optional, + property.modifiers.is_readonly, + false, + property + .modifiers + .accessibility + .unwrap_or(Accessibility::Public), + false, + false, + ) + } + ClassMember::AutoAccessor(accessor) + if side.includes(accessor.modifiers.is_static) => + { + let Some(name) = self.property_key(&accessor.name) else { + continue; + }; + let type_id = self.class_property_type( + accessor.type_annotation.as_ref(), + accessor.initializer.as_deref(), + &accessor.modifiers, + scope, + false, + ); + ( + name, + type_id, + false, + accessor.modifiers.is_readonly, + false, + accessor + .modifiers + .accessibility + .unwrap_or(Accessibility::Public), + false, + true, + ) + } + ClassMember::Method(method) if side.includes(method.modifiers.is_static) => { + match method.modifier { + PropertyModifier::None => { + let Some(name) = self.property_key(&method.name) else { + continue; + }; + if name == "constructor" { + continue; } - None => self.types.any(), - }; - let has_setter = class.members.iter().any(|candidate| { - let ClassMember::Method(candidate) = candidate.data() else { - return false; + let is_overload_signature = + method.function.body.is_none() && !method.modifiers.is_abstract; + if is_overload_signature { + overload_state.insert(name.clone(), true); + } else if overload_state.get(&name).copied().unwrap_or(false) { + overload_state.insert(name.clone(), false); + continue; + } + let signature_scope = self.class_method_signature_scope( + member.id(), + &method.function, + scope, + ); + let type_id = self.type_of_function_like_in_scope( + &method.function, + signature_scope, + ); + ( + name, + type_id, + method.optional, + false, + false, + method + .modifiers + .accessibility + .unwrap_or(Accessibility::Public), + true, + false, + ) + } + PropertyModifier::Get => { + let Some(name) = self.property_key(&method.name) else { + continue; }; - candidate.modifier == PropertyModifier::Set - && side.includes(candidate.modifiers.is_static) - && self.property_key(&candidate.name).as_deref() - == Some(name.as_str()) - }); - ( - name, - type_id, - method.optional, - !has_setter, - !has_setter, - method - .modifiers - .accessibility - .unwrap_or(Accessibility::Public), - false, - ) + let type_id = match &method.function.return_type { + Some(annotation) => { + self.resolve_type(&annotation.data().type_node, scope) + } + None => self.types.any(), + }; + let has_setter = class.members.iter().any(|candidate| { + let ClassMember::Method(candidate) = candidate.data() else { + return false; + }; + candidate.modifier == PropertyModifier::Set + && side.includes(candidate.modifiers.is_static) + && self.property_key(&candidate.name).as_deref() + == Some(name.as_str()) + }); + ( + name, + type_id, + method.optional, + !has_setter, + !has_setter, + method + .modifiers + .accessibility + .unwrap_or(Accessibility::Public), + false, + true, + ) + } + PropertyModifier::Set => continue, } - PropertyModifier::Set => continue, } - } - _ => continue, - }; + _ => continue, + }; let _ = seen.insert(name.clone()); properties.push( PropertyType::new(name, optional, type_id) .with_readonly(readonly) .with_getter_only(getter_only) .with_accessibility(access, declaring_class) - .with_method(is_method), + .with_method(is_method) + .with_accessor(accessor), ); } (properties, seen, iterator_property, async_iterator_property) @@ -11640,11 +11918,24 @@ impl<'src> Binder<'src> { ); let derived = self.class_derived_stack.last().copied().unwrap_or(false); let child = self.new_scope(ScopeKind::Function, Some(scope)); + self.push_label_scope(); let new_target_marker = self.new_target_contexts.len(); self.new_target_contexts.push(true); self.bind_implicit_function_values(&constructor.parameters, child); self.super_call_contexts .push(SuperCallContext::ConstructorParameters { derived }); + self.check_rest_parameter_last(&constructor.parameters); + if constructor.body.range().is_empty() { + for parameter in &constructor.parameters { + if parameter.data().initializer.is_some() { + self.emit( + PARAMETER_INITIALIZER_IN_SIGNATURE, + parameter.range(), + PARAMETER_INITIALIZER_IN_SIGNATURE_MESSAGE, + ); + } + } + } for parameter in &constructor.parameters { if self.is_parameter_property(parameter) { self.resolve_parameter_property(parameter, child, scope); @@ -11671,10 +11962,17 @@ impl<'src> Binder<'src> { let this_type = self.class_this_type(scope, false); self.this_context.push(this_type); self.push_reassigned_scope(); + let outer_super_flow = self.super_flow; + self.super_flow = if derived { + SuperFlow::Tracking { called: false } + } else { + SuperFlow::Suspended + }; self.in_isolated_flow(FlowNodeId::ROOT, |binder| { binder.bind_statements(&constructor.body.data().statements, child); binder.resolve_statements(&constructor.body.data().statements, child); }); + self.super_flow = outer_super_flow; if track_super { let called = self .derived_constructor_super_presence @@ -11693,6 +11991,7 @@ impl<'src> Binder<'src> { self.new_target_contexts.truncate(new_target_marker); self.super_call_contexts.pop(); let popped_home = self.super_member_homes.pop(); + self.pop_label_scope(); debug_assert_eq!(popped_home, Some(SuperMemberHome::ClassMember { derived })); } ClassMember::Property(property) => { @@ -11742,8 +12041,10 @@ impl<'src> Binder<'src> { let derived = self.class_derived_stack.last().copied().unwrap_or(false); self.super_member_homes .push(SuperMemberHome::ClassMember { derived }); + self.push_label_scope(); self.bind_statements(&block.data().statements, child); self.resolve_statements(&block.data().statements, child); + self.pop_label_scope(); let popped_home = self.super_member_homes.pop(); debug_assert_eq!(popped_home, Some(SuperMemberHome::ClassMember { derived })); self.new_target_contexts.truncate(new_target_marker); @@ -11764,6 +12065,13 @@ impl<'src> Binder<'src> { self.resolve_value(identifier, expression.id(), scope); } Expression::This => { + if let SuperFlow::Tracking { called: false } = self.super_flow { + self.emit( + SUPER_BEFORE_THIS, + expression.range(), + SUPER_BEFORE_THIS_MESSAGE, + ); + } let owner = self.this_context .last() @@ -11812,10 +12120,12 @@ impl<'src> Binder<'src> { } Expression::Arrow(arrow) => { let child = self.new_scope(ScopeKind::Function, Some(scope)); + self.push_label_scope(); // Arrows capture `this` but never inherit super-call legality. self.super_call_contexts .push(SuperCallContext::NonConstructor); self.bind_type_parameters(arrow.type_parameters.as_ref(), child); + self.check_rest_parameter_last(&arrow.parameters); for parameter in &arrow.parameters { self.resolve_non_constructor_parameter(parameter, child); } @@ -11842,6 +12152,12 @@ impl<'src> Binder<'src> { FunctionBody::Expression(_) | FunctionBody::Missing(_) => None, }; let body_flow = self.captured_flow_seed(); + // An arrow defers its captured `this`: before-super rules + // do not apply inside its body. + let outer_super_flow = self.super_flow; + self.super_flow = SuperFlow::Suspended; + let outer_guarantees = self.super_call_guarantees; + self.super_call_guarantees = true; self.push_reassigned_scope(); self.in_isolated_flow(body_flow, |binder| match &arrow.body { FunctionBody::Block(block) => { @@ -11891,21 +12207,32 @@ impl<'src> Binder<'src> { debug_assert_eq!(popped, Some(body_id)); } self.return_contexts.pop(); + self.super_flow = outer_super_flow; + self.super_call_guarantees = outer_guarantees; let popped_context = self.super_call_contexts.pop(); debug_assert_eq!(popped_context, Some(SuperCallContext::NonConstructor)); let type_id = self.type_of_arrow(arrow, scope); if self.node_types.insert(expression.id(), type_id).is_none() { self.typed_expressions.push((expression.range(), type_id)); } + self.pop_label_scope(); } Expression::Call(call) => { - if matches!(call.callee.data(), Expression::Super) { - self.check_super_call(call.callee.range()); + let is_super_call = matches!(call.callee.data(), Expression::Super); + if is_super_call { + self.check_super_call_legality(call.callee.range()); } else { self.resolve_expr(&call.callee, scope); } self.resolve_type_arguments(call.type_arguments.as_ref(), scope); self.resolve_arguments(&call.arguments, scope); + // A call's arguments evaluate before the call completes, so + // `this` inside `super(this.x)` is still before `super()`; + // the flow may only be marked guaranteed once every + // argument has resolved. + if is_super_call { + self.mark_super_call_completed(call.callee.range()); + } self.check_call(call, scope, expression.range()); } Expression::New(new) => { @@ -11916,7 +12243,11 @@ impl<'src> Binder<'src> { } Expression::Member(member) => { if matches!(member.object.data(), Expression::Super) { - self.check_super_member_access(member.object.range(), member.optional); + self.check_super_member_access( + member.object.range(), + member.optional, + &member.property, + ); } else { self.resolve_expr(&member.object, scope); // A property read dereferences its object, so a nullable @@ -12254,7 +12585,12 @@ impl<'src> Binder<'src> { /// classes and object literals are legal. Plain functions and the top /// level stay silent: no existing C-band code carries that diagnostic, and /// minting one is out of scope. - fn check_super_member_access(&mut self, range: TextRange, optional: bool) { + fn check_super_member_access( + &mut self, + range: TextRange, + optional: bool, + property: &MemberProperty, + ) { if optional { self.emit(BARE_SUPER_EXPRESSION, range, BARE_SUPER_EXPRESSION_MESSAGE); return; @@ -12266,22 +12602,117 @@ impl<'src> Binder<'src> { range, SUPER_REFERENCE_NON_DERIVED_MESSAGE, ); + return; } + if let SuperFlow::Tracking { called: false } = self.super_flow { + self.emit( + SUPER_BEFORE_SUPER_PROPERTY, + range, + SUPER_BEFORE_SUPER_PROPERTY_MESSAGE, + ); + } + self.check_super_property_is_field(property); + self.check_super_property_is_static(property); } - fn check_super_call(&mut self, range: TextRange) { + /// TS2855: `super.x` reads the prototype chain, but a class field is an + /// own instance property, so the field is never visible through `super` + /// even after `super()` has run. Methods and accessors live on the + /// prototype and stay reachable. + fn check_super_property_is_field(&mut self, property: &MemberProperty) { + let MemberProperty::Named(identifier) = property else { + return; + }; + if self.super_member_homes.last() != Some(&SuperMemberHome::ClassMember { derived: true }) { + return; + } + let Some(&owner) = self.class_owner_stack.last() else { + return; + }; + let Some(&base) = self.class_base_symbols.get(&owner) else { + return; + }; + let name = self.identifier_text(identifier); + let is_field = self + .types + .class_template_properties(base) + .iter() + .find(|member| member.name() == name.as_ref()) + .is_some_and(|member| !member.is_method() && !member.accessor()); + if is_field { + // Downlevel targets keep the single pre-fields rule: fields are + // not methods on the prototype, so the whole access is illegal + // through super. ES2015+ names the field explicitly. + if self.es5 { + self.emit( + SUPER_PROPERTY_NOT_METHOD, + identifier.range(), + SUPER_PROPERTY_NOT_METHOD_MESSAGE, + ); + } else { + self.emit_with_message( + SUPER_FIELD_VIA_SUPER, + identifier.range(), + format!( + "Class field '{name}' defined by the parent class is not accessible in the child class via super." + ), + ); + } + } + } + + /// TS2576: `super` resolves instance members, so a base class static + /// reached through it is a misspelling of `Base.member`. + fn check_super_property_is_static(&mut self, property: &MemberProperty) { + let MemberProperty::Named(identifier) = property else { + return; + }; + if self.super_member_homes.last() != Some(&SuperMemberHome::ClassMember { derived: true }) { + return; + } + let Some(&owner) = self.class_owner_stack.last() else { + return; + }; + let Some(&base) = self.class_base_symbols.get(&owner) else { + return; + }; + let Some(&static_type) = self.class_constructor_types.get(&base) else { + return; + }; + let name = self.identifier_text(identifier); + // The static side is a constructor type wrapping the structural + // member table. + let Type::ConstructorType { structural, .. } = self.types.get(static_type).clone() else { + return; + }; + let Type::ObjectType(object) = self.types.get(structural).clone() else { + return; + }; + if !object + .properties + .iter() + .any(|member| member.name() == name.as_ref()) + { + return; + } + let base_name = self.symbols[base.get() as usize].name().to_owned(); + self.emit_with_message( + SUPER_STATIC_MEMBER_VIA_SUPER, + identifier.range(), + format!( + "Property '{name}' does not exist on type '{base_name}'. Did you mean to access the static member '{base_name}.{name}' instead?" + ), + ); + } + + fn check_super_call_legality(&mut self, range: TextRange) { let context = self .super_call_contexts .last() .copied() .unwrap_or(SuperCallContext::NonConstructor); let (code, message) = match context { - SuperCallContext::DerivedConstructor => { - if let Some(called) = self.derived_constructor_super_presence.last_mut() { - *called = true; - } - return; - } + SuperCallContext::DerivedConstructor => return, SuperCallContext::BaseConstructor | SuperCallContext::ConstructorParameters { derived: false } => ( SUPER_REFERENCE_NON_DERIVED, @@ -12299,6 +12730,23 @@ impl<'src> Binder<'src> { self.emit(code, range, message); } + /// Records a resolved `super()` call: the constructor's missing-super + /// presence is satisfied, and in statement position the flow becomes + /// guaranteed. Runs after the call's arguments have resolved. + fn mark_super_call_completed(&mut self, _range: TextRange) { + if self.super_call_contexts.last().copied() != Some(SuperCallContext::DerivedConstructor) { + return; + } + if let Some(called) = self.derived_constructor_super_presence.last_mut() { + *called = true; + } + if self.super_call_guarantees + && let SuperFlow::Tracking { called } = &mut self.super_flow + { + *called = true; + } + } + fn resolve_object_member(&mut self, member: &'src ObjectMember, scope: ScopeId) { match member { ObjectMember::Property(property) => { @@ -14016,7 +14464,7 @@ impl<'src> Binder<'src> { } AssignmentTarget::Member(member) => { if matches!(member.object.data(), Expression::Super) { - self.check_super_member_access(member.object.range(), false); + self.check_super_member_access(member.object.range(), false, &member.property); } else { self.resolve_expr(&member.object, scope); } @@ -14214,6 +14662,7 @@ impl<'src> Binder<'src> { USED_BEFORE_ASSIGNED_MESSAGE, ); } + self.check_used_before_declaration(identifier, symbol, scope); } else if !self.suppresses_unresolved_value(scope) { if self.intrinsics.is_lib_gated_value(&name) { self.emit( @@ -14250,6 +14699,75 @@ impl<'src> Binder<'src> { } false } + /// TS2448/TS2449/TS2450: a block-scoped binding referenced textually + /// before its declaration. A reference separated from the declaration by + /// a function-like boundary is deferred instead - the function runs + /// after the binding initializes - so only same-function (or same + /// module top-level) uses count. + fn check_used_before_declaration( + &mut self, + identifier: &IdentifierNode, + symbol: SymbolId, + scope: ScopeId, + ) { + let symbol_data = &self.symbols[symbol.get() as usize]; + let code = match symbol_data.kind { + SymbolKind::Variable(VariableKind::Let | VariableKind::Const) => { + BLOCK_SCOPED_USED_BEFORE_DECLARATION + } + SymbolKind::Class => CLASS_USED_BEFORE_DECLARATION, + SymbolKind::Enum => { + // A const enum is inlined at its use sites, so an early + // reference reads the cooked value instead of the runtime + // binding; tsc leaves it clean. + let is_const = self + .enum_declarations + .iter() + .any(|binding| binding.symbol == symbol && binding.declaration.is_const); + if is_const { + return; + } + ENUM_USED_BEFORE_DECLARATION + } + _ => return, + }; + let declaration_scope = symbol_data.scope(); + if identifier.range().start() >= symbol_data.range().start() + || self.boundary_scope(scope) != self.boundary_scope(declaration_scope) + || self.crosses_function_boundary(scope, declaration_scope) + { + return; + } + let name = self.identifier_text(identifier); + let message = match symbol_data.kind { + SymbolKind::Variable(_) => { + format!("Block-scoped variable '{name}' used before its declaration.") + } + SymbolKind::Class => format!("Class '{name}' used before its declaration."), + _ => format!("Enum '{name}' used before its declaration."), + }; + self.emit_with_message(code, identifier.range(), message); + } + + /// Whether walking from `from` up to `to` crosses a function-like + /// boundary. + fn crosses_function_boundary(&self, from: ScopeId, to: ScopeId) -> bool { + let mut scope = from; + loop { + if scope == to { + return false; + } + match self.scopes[scope.0 as usize].kind { + ScopeKind::Function | ScopeKind::Global | ScopeKind::Module => return true, + _ => {} + } + let Some(parent) = self.scopes[scope.0 as usize].parent else { + return true; + }; + scope = parent; + } + } + fn enclosing_this_owner(&self, mut scope: ScopeId) -> Option { loop { let lexical = &self.scopes[scope.0 as usize]; diff --git a/crates/bamts-compiler/src/diagnostics_parser.rs b/crates/bamts-compiler/src/diagnostics_parser.rs index b8a991de..51b62732 100644 --- a/crates/bamts-compiler/src/diagnostics_parser.rs +++ b/crates/bamts-compiler/src/diagnostics_parser.rs @@ -58,6 +58,8 @@ const TS1136: DiagnosticCode = DiagnosticCode::new("TS1136"); const TS1160: DiagnosticCode = DiagnosticCode::new("TS1160"); /// Unterminated regular expression literal. const TS1161: DiagnosticCode = DiagnosticCode::new("TS1161"); +const TS1163: DiagnosticCode = DiagnosticCode::new("TS1163"); +const TS1308: DiagnosticCode = DiagnosticCode::new("TS1308"); /// Expected corresponding JSX closing tag. const TS17002: DiagnosticCode = DiagnosticCode::new("TS17002"); /// JSX fragment has no corresponding closing tag. @@ -88,6 +90,8 @@ pub fn typescript_parse_code(code: DiagnosticCode, message: &str) -> Option TS1003, "BAMTS-P015" => TS17015, "BAMTS-P016" => TS17014, + "BAMTS-P017" => TS1163, + "BAMTS-P018" => TS1308, "BAMTS-C051" if is_export_modifier_on_class_element(message) => TS1031, _ => return None, }) @@ -123,6 +127,10 @@ pub fn typescript_parse_message(code: DiagnosticCode) -> Option<&'static str> { "TS1136" => "Property assignment expected.", "TS1160" => "Unterminated template literal.", "TS1161" => "Unterminated regular expression literal.", + "TS1163" => "A 'yield' expression is only allowed in a generator body.", + "TS1308" => { + "'await' expressions are only allowed within async functions and at the top levels of modules." + } "TS17002" => "Expected corresponding JSX closing tag for '{0}'.", "TS17008" => "JSX element '{0}' has no corresponding closing tag.", "TS17014" => "JSX fragment has no corresponding closing tag.", diff --git a/crates/bamts-compiler/src/emitter.rs b/crates/bamts-compiler/src/emitter.rs index 04eeaa35..84c68431 100644 --- a/crates/bamts-compiler/src/emitter.rs +++ b/crates/bamts-compiler/src/emitter.rs @@ -6769,4 +6769,1457 @@ var c = () => 1; ["@accessorFirst", "@accessorSecond"] ); } + + #[test] + fn pr199_for_of_assignment_targets_lower_to_indexed_loops() { + let source = "var target = {}; for (target[2 ** 1] of source(2 ** 3)) { use(target); }"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(source).expect("fits")), + )); + assert!(parsed.diagnostics().is_empty()); + let statement = parsed.product().statements().last().expect("loop"); + let crate::syntax::Statement::ForOf(for_of) = statement.data() else { + panic!("expected a for-of AST for {source}"); + }; + assert!(matches!( + for_of.binding, + crate::syntax::ForBinding::Target(_) + )); + let output = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + let code = &javascript(&output).code; + assert!(!code.contains(" of "), "native for-of survived ES5: {code}"); + assert_eq!(code.matches("Math.pow(2, 3)").count(), 1, "{code}"); + assert_eq!(code.matches("Math.pow(2, 1)").count(), 1, "{code}"); + assert!(!code.contains("**"), "{code}"); + let assign = code + .find("target[Math.pow(2, 1)] = ") + .expect("indexed assignment"); + let use_call = code.find("use(target)").expect("body"); + assert!(assign < use_call, "{code}"); + } + + fn pr199_emit_at(input: &str, target: ScriptTarget) -> EmitOutput { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("fits")), + )); + assert!( + parsed.diagnostics().is_empty(), + "{:?}", + parsed.diagnostics() + ); + emit_output( + parsed.product(), + &EmitOptions { + target, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ) + } + + #[test] + fn pr199_native_declarations_emit_keys_in_declarator_order() { + let source = "declare function before(): number; \ + declare function key(): string; \ + declare function between(): number; \ + declare function laterKey(): string; \ + declare function after(): number; \ + declare function finish(): void; \ + function* g() { \ + const left = before(), C = class { [yield key()]() {} }, \ + middle = between(), D = class { [yield laterKey()]() {} }, \ + right = after(); \ + finish(); return [left, C, middle, D, right]; \ + }"; + for target in [ScriptTarget::Es2015, ScriptTarget::Es2016] { + let output = pr199_emit_at(source, target); + assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics); + let code = &javascript(&output).code; + let positions = [ + "before()", + "key()", + "C =", + "between()", + "laterKey()", + "D =", + "after()", + "finish()", + ] + .map(|needle| { + assert_eq!(code.matches(needle).count(), 1, "{needle}: {code}"); + code.find(needle).expect("one occurrence") + }); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{code}"); + } + } + + #[test] + fn pr199_async_native_declarations_do_not_lose_key_awaits() { + let source = "declare function before(): number; \ + declare function key(): string; declare function after(): number; \ + async function g() { \ + const left = before(), C = class { [await key()]() {} }, right = after(); \ + return [left, C, right]; \ + }"; + let output = pr199_emit_at(source, ScriptTarget::Es2015); + assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics); + let code = &javascript(&output).code; + let positions = ["before()", "key()", "C =", "after()"].map(|needle| { + assert_eq!(code.matches(needle).count(), 1, "{needle}: {code}"); + code.find(needle).expect("one occurrence") + }); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{code}"); + assert!( + code.contains("yield"), + "await must survive as a suspension: {code}" + ); + assert!(!code.contains("await key()"), "raw await leaked: {code}"); + } + + #[test] + fn es5_async_for_update_increment_lowers() { + // Synthetic probe pinning the walker/machine contract: a for-update + // `i++` (Update over an Identifier) counts as zero resumes after the + // walker fix, so a suspending for-loop still lowers normally with + // the update emitted as plain machine code. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "declare var x: any, y: any, i: any;\nasync function f() {\n for (; x; i++) { await y; }\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + let expected = "function f() {\n return __awaiter(this, void 0, void 0, function () {\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0: _a.label = 1;\n case 1:\n if (!x) return [3 /*break*/, 4];\n return [4 /*yield*/, y];\n case 2:\n _a.sent();\n _a.label = 3;\n case 3:\n i++;\n return [3 /*break*/, 1];\n case 4: return [2 /*return*/];\n }\n });\n });\n}"; + assert!(code.contains(expected), "{code}"); + assert!(!code.contains("function*"), "{code}"); + } + + #[test] + fn es5_generator_update_computed_key_refuses() { + // Synthetic probe pinning the walker/machine contract: a while-test + // computed-key yield (`o[yield k]++` inside the test) counts as a + // resume, so the loop cannot stay inline; lowering routes to eval, + // which refuses with GENERATOR_REQUIRES_ES2015, and the native + // generator survives — never the inline machine clone. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = + "declare var o: any, k: any;\nfunction* g() {\n while (o[yield k]++ < 3) { }\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(!code.contains("__generator(this,"), "{code}"); + assert!(code.contains("function* g("), "{code}"); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "{:?}", + output.diagnostics + ); + } + + #[test] + fn es5_async_temp_collision_with_user_var() { + // Authority: awaitBinaryExpression5_es5 / asyncFunctionDeclaration9 — + // when a user variable collides with the machine's default temp name, + // the machine state parameter is renamed to _b and the user's _a stays + // the hoisted var. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = + "async function f(x: any) {\n var _a = 1;\n await x;\n return _a;\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(code.contains("function (_b)"), "{code}"); + assert!(code.contains("_b.label"), "{code}"); + assert!(code.contains("_b.sent()"), "{code}"); + assert!(code.contains("var _a;"), "{code}"); + assert!(code.contains("_a = 1;"), "{code}"); + } + + #[test] + fn es5_async_leaked_await_refuses() { + // Authority: es5-asyncFunction(target=es5) leaked await in left-hand + // of property access assignment cannot be lowered, so the native + // generator fallback is kept. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "async function f(a: any, b: any, c: any) {\n (await a).b = c;\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(code.contains("function* ()"), "{code}"); + assert!(!code.contains("__generator(this,"), "{code}"); + assert!(!code.contains(".sent()"), "{code}"); + } + + #[test] + fn es5_async_binary_expression_await_materializes() { + // Authority: awaitBinaryExpression5_es5(target=es5) — the awaited + // operand is evaluated first, then combined with the saved left value + // through `.sent()`. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "async function f(a: any, b: any) {\n return a + (await b);\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(code.contains("function (_b)"), "{code}"); + assert!(code.contains("_b.label"), "{code}"); + assert!(code.contains("_a + (_b.sent())"), "{code}"); + } + + #[test] + fn es5_async_switch_discriminant_await_inline() { + // Return-bearing cases must not ride the discriminant-inline path: + // cloning them verbatim embeds a raw `return 10` in the machine's + // inner function, which drops the value through the protocol. Until + // the rebuilt-cases path routes returns through machine_statements, + // this shape refuses with the diagnostic — never a silent clone. + let output = emit_es5_clean( + "declare var x: any;\nasync function f() { switch (await x) { case 1: return 10; default: return 20; } }\n", + ); + let code = &javascript(&output).code; + assert!( + !code.contains("__generator(this,"), + "raw returns must not clone into the machine: {code}" + ); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "refusal must signal: {:?}", + output.diagnostics + ); + // A return-free discriminant case still lowers inline. + let lowered = emit_es5_clean( + "declare var x: any;\nasync function f() { switch (await x) { case 1: r = 10; } }\n", + ); + let code = &javascript(&lowered).code; + assert!(code.contains("switch (_a.sent())"), "{code}"); + } + + #[test] + fn es5_async_labeled_loop_with_await_and_break_lowers() { + // Authority: es5-asyncFunctionWhileStatements(target=es5) — a labeled + // while body with an await and a labeled break lowers to the machine; + // the break becomes an explicit branch to the exit label. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "declare var x: any, y: any;\nasync function f() {\n A: while (x) { await y; break A; }\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(!code.contains("function*"), "{code}"); + assert!( + code.contains("__awaiter(this, void 0, void 0, function ("), + "{code}" + ); + assert!(code.contains("if (!x) return [3 /*break*/, 2];"), "{code}"); + assert!(code.contains("_a.sent();"), "{code}"); + assert!(code.contains("return [3 /*break*/, 2];"), "{code}"); + } + + #[test] + fn es5_async_identifier_assignment_test_stays_inline() { + // Authority: es5-asyncFunctionWhileStatements(target=es5) — a while + // test that only assigns to an identifier carries no resume, so the + // whole test clones verbatim into the machine instead of routing the + // loop through eval. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "declare var x: any, y: any;\nasync function f() {\n while (x = 1) { await y; }\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(code.contains("if (!(x = 1))"), "{code}"); + assert_eq!( + code.matches("return [4 /*yield*/, y];").count(), + 1, + "{code}" + ); + assert!(!code.contains("function*"), "{code}"); + } + #[test] + fn es5_generator_object_spread_yield_refuses() { + // Contract pin, not a mutation claim: a while-test object spread + // carrying a yield (`{ ...(yield x) }`) is refused upstream by the + // machine's object-member wildcard, so the native generator survives + // whether or not count_yields tracks the spread — this pins the + // refusal shape: never the inline machine clone. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "declare var x: any;\nfunction* g() {\n while ({ ...(yield x) }) { }\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(code.contains("function* g("), "{code}"); + assert!(!code.contains("__generator(this,"), "{code}"); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "{:?}", + output.diagnostics + ); + } + + #[test] + fn es5_generator_array_spread_yield_never_clones() { + // Mutation-provable: an array-literal spread carrying a yield + // (`[...(yield x)]` as a statement) must never lower to the inline + // machine — contains_yield_array reports the spread's yield, the + // lowering bails, and the native generator survives with + // GENERATOR_REQUIRES_ES2015. Reverting the Spread arm resurrects + // `__generator(this, ...)` wrapping a live yield. + let options = EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }; + let input = "declare var x: any;\nfunction* g() {\n [...(yield x)];\n}\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output(parsed.product(), &options); + let code = &javascript(&output).code; + assert!(code.contains("function* g("), "{code}"); + assert!(!code.contains("__generator(this,"), "{code}"); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "{:?}", + output.diagnostics + ); + } + + // ------------------------------------------------------------------ + // Adversarial hardening: the walker-duality contract as an invariant, + // not a shape list. Assumptions under test (IDs cited per test): + // WD-1 duality: no emitted machine embeds a live yield outside a + // `return [4 /*yield*/, ...]` split (generic leak detector). + // WD-2 sentinel shapes (TaggedTemplate/Satisfies/TypeAssertion/JSX) + // are eval-refused, never inline-cloned. + // WD-3 count == segments eval mints: machine case labels are + // contiguous 0..=max and unique (gap detector; boundaries + // 0/1/2/3 suspensions). + // WD-4 nested function-likes own their suspensions: cloning them + // is correct, their internal yield is legal. + // WD-5 non-block loop bodies refuse, never miscompile. + // WD-6/7 Import (source+options) and Conditional/Logical counts + // feed exact label arithmetic. + // WD-8 labeled-loop delegation covers nested awaited bodies. + // WD-9 recursive walkers survive deep nesting without crashing. + // ------------------------------------------------------------------ + + /// B4: destructuring defaults lower to tsc's ES5 shape - a temp + /// read then a `=== void 0` ternary - never erasing the default. + #[test] + fn destructuring_defaults_lower_to_ternary_declarators() { + let cases = [ + ( + "var o:any;\nvar { a = 1 } = o;\n", + "var _t0 = o.a, a = _t0 === void 0 ? 1 : _t0;", + ), + ( + "var o:any;\nvar { p: q = 2 } = o;\n", + "var _t0 = o.p, q = _t0 === void 0 ? 2 : _t0;", + ), + ( + "var o:any;\nvar { x: { y } = {} } = o;\n", + "var _t0 = o.x, _t1 = _t0 === void 0 ? {} : _t0, y = _t1.y;", + ), + ( + "var arr:any;\nvar [c = 3, , [d = 4] = []] = arr;\n", + "var _t0 = arr[0], c = _t0 === void 0 ? 3 : _t0, _t1 = arr[2], _t2 = _t1 === void 0 ? [] : _t1, _t3 = _t2[0], d = _t3 === void 0 ? 4 : _t3;", + ), + ]; + for (src, expected) in cases { + let out = emit_es5_clean(src); + let code = javascript(&out).code.clone(); + assert!( + code.contains(expected), + "missing tsc default shape `{expected}` in:\n{code}" + ); + } + } + + /// B4: a default's suspension survives lowering - the machine splits + /// the default selection and no live yield leaks. + #[test] + fn destructuring_default_suspension_splits_the_machine() { + let out = + emit_es5_clean("var o:any, k:any;\nfunction* g(){ var { m = (yield k) } = o; }\n"); + let code = javascript(&out).code.clone(); + assert!(code.contains("__generator(this,"), "machine form: {code}"); + assert!( + code.contains("=== void 0"), + "selection test missing:\n{code}" + ); + assert!( + code.contains("[4 /*yield*/, k]"), + "suspension missing:\n{code}" + ); + assert!(code.contains("m = _t"), "final bind missing:\n{code}"); + assert!(live_yield_leak(&code).is_none(), "live yield leaked"); + } + + /// B4: an await default must not land in a ternary - eval refuses + /// Conditional, so the __awaiter inner generator would stay native + /// `function*` at ES5. The branch form keeps the inner machine. + #[test] + fn destructuring_default_await_keeps_the_inner_machine() { + let out = emit_es5_clean( + "var o:any, k:any;\nasync function h(){ var { n = await k } = o; return n; }\n", + ); + let code = javascript(&out).code.clone(); + assert!(code.contains("__awaiter("), "async wrapper: {code}"); + assert!( + code.contains("__generator(this,"), + "inner machine lost - await landed in a ternary:\n{code}" + ); + assert!(!code.contains("? yield"), "ternary yield:\n{code}"); + } + + /// B4 walker: a suspending for-init binding signals refusal with the + /// requires-es2015 diagnostic rather than cloning silently. + #[test] + fn for_init_suspending_binding_signals_refusal() { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new( + "var o:any, k:any;\nfunction* g(){ for (var { a = (yield k) } = o;;) { break; } }\n", + ) + .expect("fits")), + )); + let out = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.message().contains("generators require")), + "refusal must be signalled" + ); + } + + /// B1: a suspending computed class key hoists out of the class + /// IIFE in statement contexts - the arrow is a plain function and + /// tsc 6.0.2 itself emits a SyntaxError for the in-IIFE form. The + /// machine splits the hoisted key temp and the arrow stays clean. + #[test] + fn class_computed_key_suspension_hoists_out_of_the_iife() { + let out = emit_es5_clean( + "var k:any;\nfunction* g(){ var C = class { [yield k]() {} }; return C; }\n", + ); + let code = javascript(&out).code.clone(); + assert!(code.contains("__generator(this,"), "machine form: {code}"); + assert!( + code.contains("[4 /*yield*/, k]"), + "the key's suspension must split:\n{code}" + ); + assert!( + code.contains("_t1 = _a.sent()"), + "the key temp binds from sent():\n{code}" + ); + assert!( + code.contains("[_t1]()"), + "the member reads the hoisted temp:\n{code}" + ); + assert!(live_yield_leak(&code).is_none(), "live yield leaked"); + } + + /// B1: expression-position class expressions cannot hoist (no + /// enclosing statement drains key_prelude), so they keep tsc's + /// in-IIFE shape but carry the requires-es2015 refusal instead of + /// breaking silently. + #[test] + fn class_computed_key_in_expression_position_signals_refusal() { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new( + SourceText::new("var k:any;\nfunction* h(){ foo(class { [yield k]() {} }); }\n") + .expect("fits"), + ), + )); + let out = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.message().contains("generators require")), + "expression-position suspension must refuse loudly" + ); + } + + /// B7: if-without-else is the empty else, not a refusal - both the + /// inline clean clone and the suspending labeled shape must lower. + #[test] + fn if_without_else_lowers_in_the_machine() { + let suspending = + emit_es5_clean("var c:any, x:any;\nfunction* g(){ if (c) { yield x; } yield 2; }\n"); + let code = javascript(&suspending).code.clone(); + assert!(code.contains("__generator(this,"), "machine form: {code}"); + assert!( + code.contains("[4 /*yield*/, x]"), + "the branch suspension must split:\n{code}" + ); + assert!(live_yield_leak(&code).is_none(), "live yield leaked"); + let clean = + emit_es5_clean("var c:any, x:any;\nfunction* g(){ yield 1; if (c) { x = 1; } }\n"); + let code = javascript(&clean).code.clone(); + assert!(code.contains("__generator(this,"), "machine form: {code}"); + assert!(code.contains("if (c)"), "clean if clones inline:\n{code}"); + } + + /// Bot-round B4 gaps: the suspending-declaration path must keep + /// sibling identifier declarators, declare computed-key temps, + /// bind array rest, exclude computed keys from object rest with + /// tsc's runtime coercion, and rewrite awaited sources so the + /// __awaiter inner generator still machine-converts. + #[test] + fn suspending_declarations_keep_sibling_and_rest_semantics() { + let out = emit_es5_clean( + "var y:any, obj:any;\nfunction* g(){ var x = 5, { a = (yield y) } = obj; }\n", + ); + let code = javascript(&out).code.clone(); + assert!( + code.contains("x = 5"), + "sibling declarator dropped:\n{code}" + ); + let out = emit_es5_clean( + "var k:any, arr:any;\nfunction* g(){ var [a = (yield k), ...r] = arr; }\n", + ); + let code = javascript(&out).code.clone(); + assert!( + code.contains("r = arr.slice(1)"), + "array rest discarded:\n{code}" + ); + let out = + emit_es5_clean("var k:any, o:any;\nfunction* g(){ var { [k]: v, ...rest } = o; }\n"); + let code = javascript(&out).code.clone(); + assert!( + code.contains("typeof _t0 === \"symbol\" ? _t0 : _t0 + \"\""), + "computed key must be excluded at runtime (tsc shape):\n{code}" + ); + let out = emit_es5_clean("var k:any, o:any;\nfunction* g(){ var { [yield k]: v } = o; }\n"); + let code = javascript(&out).code.clone(); + assert!(code.contains("var v, _t0, _t1"), "temps declared:\n{code}"); + assert!(live_yield_leak(&code).is_none(), "live yield leaked"); + let out = emit_es5_clean("var k:any;\nasync function h(){ var { a } = await k; }\n"); + let code = javascript(&out).code.clone(); + assert!( + code.contains("__generator(this,"), + "awaited source must rewrite so the inner machine converts:\n{code}" + ); + } + + /// B1 order: computed keys evaluate in member order, so hoisting + /// must move the whole prelude - partitioning suspending keys away + /// from clean ones reorders evaluation. + #[test] + fn class_computed_keys_evaluate_in_member_order() { + let out = emit_es5_clean( + "var k1:any, k2:any;\nfunction* g(){ var C = class { [k1()](){} [yield k2()](){} }; }\n", + ); + let code = javascript(&out).code.clone(); + let clean_first = code.find("k1()").expect("clean key evaluated"); + let suspending = code.find("k2()").expect("suspending key evaluated"); + assert!( + clean_first < suspending, + "member order must survive hoisting:\n{code}" + ); + assert!(live_yield_leak(&code).is_none(), "live yield leaked"); + let out = emit_es5_clean( + "var k1:any, k2:any;\nfunction* g(){ var C = class { [yield k1()](){} [k2()](){} }; }\n", + ); + let code = javascript(&out).code.clone(); + let suspending = code.find("k1()").expect("suspending key evaluated"); + let clean_second = code.find("k2()").expect("clean key evaluated"); + assert!( + suspending < clean_second, + "member order must survive hoisting (suspending first):\n{code}" + ); + } + + /// B1 visibility: an await inside a class computed key or heritage + /// clause in expression position cannot lower - there is no hoist + /// channel - and must refuse the generator natively under the + /// requires-es2015 diagnostic, never bury the raw await inside a + /// synthesized arrow the machine cannot see through. + #[test] + fn class_expression_awaits_refuse_natively() { + for src in [ + "var k:any;\nasync function h(){ foo(class { [await k](){} }); }\n", + "var f:any;\nasync function h(){ return class extends (await f()) {}; }\n", + ] { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(src).expect("fits")), + )); + let out = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + let code = javascript(&out).code.clone(); + assert!( + !code.contains("__generator(this,"), + "machine must refuse: {src}\n{code}" + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "refusal must be signalled: {src}" + ); + } + } + + /// A suspending static-field initializer (postlude) refuses the + /// same way a key prelude does: the unlowered class keeps the + /// suspension visible and the generator stays native under the + /// signal - never a live yield inside the synthesized arrow. + #[test] + fn class_static_field_suspension_refuses_natively() { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new( + SourceText::new( + "var k:any;\nfunction* g(){ var C = class { static x = yield k; }; }\n", + ) + .expect("fits"), + ), + )); + let out = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + let code = javascript(&out).code.clone(); + assert!( + !code.contains("__generator(this,"), + "machine must refuse the static-field suspension:\n{code}" + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "refusal must be signalled" + ); + } + + /// A for-of body suspending in a generator machine-splits through + /// the lowered classic loop - the ladder's machine slice composing + /// with the plain lowering. + #[test] + fn for_of_suspension_machine_splits_through_the_lowered_loop() { + let out = + emit_es5_clean("var it:any;\nfunction* g(){ for (const k of it) { yield k; } }\n"); + let code = javascript(&out).code.clone(); + assert!(code.contains("__generator(this,"), "machine form: {code}"); + assert!( + code.contains("[4 /*yield*/, k]"), + "the body's suspension must split:\n{code}" + ); + assert!( + code.contains("_t1[_t0]"), + "the binding reads the indexed element:\n{code}" + ); + assert!(live_yield_leak(&code).is_none(), "live yield leaked"); + } + + /// A braceless for-of body is one statement, not a missing one: + /// the emitted loop must still contain it. + #[test] + fn braceless_for_of_body_survives_lowering() { + let out = + emit_es5_clean("var arr:any;\nfunction f(){ for (const k of arr) console.log(k); }\n"); + let code = javascript(&out).code.clone(); + assert!( + code.contains("console.log(k)"), + "the braceless body statement must survive:\n{code}" + ); + } + + /// A destructuring binding in a for-of head composes with the + /// destructuring lowering: the element read becomes the binding's + /// initializer inside the loop body. + #[test] + fn for_of_pattern_binding_composes_with_destructuring() { + let out = emit_es5_clean( + "var arr:any;\nfor (const { a, b = 2 } of arr) { console.log(a + b); }\n", + ); + let code = javascript(&out).code.clone(); + assert!( + code.contains("void 0 ? 2 :"), + "the binding default must lower inside the loop:\n{code}" + ); + assert!( + code.contains(".a"), + "the member read must bind from the element:\n{code}" + ); + } + + /// Emits `input` at es5 without helpers; panics on parse diagnostics. + fn emit_es5_clean(input: &str) -> EmitOutput { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input).expect("test source fits the per-file budget")), + )); + assert!(parsed.diagnostics().is_empty()); + emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ) + } + + /// WD-1: in machine form, every `yield` must sit inside a split marker + /// `/*yield*/`; a bare yield is a live suspension cloned into a plain + /// function (the miscompile class). Returns the offending snippet text. + fn live_yield_leak(code: &str) -> Option { + if !code.contains("__generator(this,") { + return None; // native refusal form: the generator owns its yields + } + code.lines() + // Strip the protocol marker spans first: a line can carry both a + // legitimate `return [4 /*yield*/, x]` split and a cloned live + // yield; the marker must not exempt the whole line. + .find(|line| { + let stripped = line.replace("/*yield*/", ""); + // The match must end as a token: `yielding` is an + // identifier, not the keyword. + stripped.match_indices("yield").any(|(i, _)| { + stripped[i + 5..] + .chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '$')) + }) + }) + .map(|line| line.trim().to_owned()) + } + + /// WD-1b: in machine form, every return must be a protocol return + /// (`return [`) — a bare `return expr;` in the machine body exits the + /// inner function directly and the runtime silently drops the value. + /// Battery-scoped: snippets are flat (no nested function bodies, whose + /// returns are legal); the nested-ownership contract has its own pin. + fn raw_return_leak(code: &str) -> Option { + if !code.contains("__generator(this,") { + return None; + } + code.lines() + .find(|line| { + let l = line.trim(); + // `return` must end as a token: `returning = 1;` is a + // plain assignment, not a protocol-breaking return. + let boundary = !l.starts_with("return") + || l[6..] + .chars() + .next() + .is_none_or(|c| c.is_whitespace() || c == ';' || c == '}' || c == '('); + l.starts_with("return") + && boundary + && !l.starts_with("return [") + && !l.starts_with("return __generator") + && !l.starts_with("return __awaiter") + && l.ends_with(';') + }) + .map(|line| line.trim().to_owned()) + } + + /// WD-3: the machine switch's case labels must be unique and contiguous + /// from 0 (a gap or duplicate means count_yields disagreed with the + /// segments eval actually minted). + fn machine_case_labels(code: &str) -> Vec { + // Any state parameter (_a, or _b after a temp collision); raw + // labels with no dedup — the caller's equality assert fails on a + // duplicate, which is the failure this helper guards. + let start = code + .match_indices("switch (_") + .map(|(i, _)| i) + .find(|&i| code[i..].contains(".label)")) + .expect("machine switch"); + let body = &code[start..]; + let mut labels: Vec = body + .split("case ") + .skip(1) + .filter_map(|rest| rest.split(':').next()) + .filter_map(|n| n.parse().ok()) + .collect(); + labels.sort_unstable(); + labels + } + + #[test] + fn es5_async_return_in_loop_body_never_drops_value() { + // A cloned `return y` inside the machine body returns straight out + // of the inner function; the runtime treats the non-array as done + // with `void 0` — the value is silently dropped. The loop body + // cannot lower a return, so the machine must refuse (until a + // return-protocol slice lands), never clone. + let output = emit_es5_clean( + "declare var x: any, y: any;\nasync function f() { while (x) { return y; } }\n", + ); + let code = &javascript(&output).code; + assert!( + !code.contains("__generator(this,"), + "machine must not embed a raw return: {code}" + ); + assert!(code.contains("return y;"), "value preserved: {code}"); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "refusal must signal: {:?}", + output.diagnostics + ); + } + + #[test] + fn walker_duality_battery_never_clones_live_yield() { + // WD-1: each snippet puts a suspending shape behind each clone gate, + // with its derived expectation: `true` lowers to the machine (and + // must not leak), `false` refuses (native form + the requires-es2015 + // diagnostic + no machine). Expectations were derived from emitted + // output; all-refusing output fails every `true` row. + let snippets: [(&str, &str, bool); 31] = [ + ( + "update-computed-while", + "declare var o: any, k: any;\nfunction* g() { while (o[k ? k : 0][`x`], o[yield k]++ < 3) { } }\n", + false, + ), + ( + "array-spread-statement", + "declare var x: any;\nfunction* g() { [...(yield x)]; }\n", + false, + ), + ( + "array-spread-while-body", + "declare var x: any, y: any;\nfunction* g() { while (y) { [...(yield x)]; } }\n", + false, + ), + ( + "object-spread-do-test", + "declare var x: any;\nfunction* g() { do { } while ({ ...(yield x) }); }\n", + false, + ), + ( + "assignment-pattern-while", + "declare var x: any, y: any;\nfunction* g() { while ({ a: x } = y) { yield x; } }\n", + false, + ), + ( + "template-expr-statement", + "declare var x: any;\nfunction* g() { `${yield x}`; }\n", + true, + ), + ( + "conditional-expr-statement", + "declare var x: any, y: any;\nfunction* g() { return (yield x) ? y : (yield x); }\n", + false, + ), + ( + "logical-expr-statement", + "declare var x: any, y: any;\nfunction* g() { return (yield x) || (yield y); }\n", + false, + ), + ( + "import-expr-machine", + "declare var x: any;\nfunction* g() { return import(yield x); }\n", + true, + ), + ( + "new-suspending-args", + "declare var x: any;\nfunction* g() { return new C(yield x); }\n", + false, + ), + ( + "nested-if-await-while", + "declare var x: any, y: any, z: any;\nasync function f() { while (x) { if (y) { await z; } } }\n", + false, + ), + ( + "nested-if-await-labeled", + "declare var x: any, y: any, z: any;\nasync function f() { A: while (x) { if (y) { await z; break A; } } }\n", + false, + ), + ( + "nested-if-await-do", + "declare var x: any, y: any, z: any;\nasync function f() { do { if (y) { await z; } } while (x); }\n", + false, + ), + ( + "nested-if-await-if-arm", + "declare var x: any, y: any, z: any;\nasync function f() { if (x) { if (y) { await z; } } }\n", + false, + ), + ( + "return-in-while-body", + "declare var x: any, y: any;\nasync function f() { while (x) { return y; } }\n", + false, + ), + ( + "return-in-labeled-body", + "declare var x: any, y: any;\nasync function f() { A: while (x) { return y; } }\n", + false, + ), + ( + "return-in-if-branch", + "declare var y: any, z: any;\nasync function f() { if (y) { return z; } }\n", + false, + ), + ( + "clean-nested-while-clones", + "declare var x: any, z: any;\nasync function f() { while (x) { while (z) { } } }\n", + true, + ), + ( + "call-spread-arg", + "declare var f: any, x: any;\nfunction* g() { f(...(yield x)); }\n", + false, + ), + ( + "method-computed-name", + "declare var k: any;\nfunction* g() { ({ [yield k]() {} }); }\n", + false, + ), + ( + "nonblock-nested-return", + "declare var x: any, z: any;\nasync function f() { while (x) { while (z) return z; } }\n", + false, + ), + ( + "as-cast-lowers", + "declare var k: any;\nfunction* g() { while ((yield k) as any) { } }\n", + true, + ), + ( + "call-spread-while-test", + "declare var f: any, x: any;\nfunction* g() { while (f(...(yield x)) < 3) { } }\n", + false, + ), + ( + "method-computed-while-test", + "declare var k: any;\nfunction* g() { while ({ [yield k]() {} }) { } }\n", + false, + ), + ( + "yield-nested-lowers-with-exact-labels", + "declare var a: any;\nfunction* g() { while (yield (yield a)) {} }\n", + true, + ), + ( + "for-var-init-yield-splits", + "declare var x: any, c: any;\nfunction* g() { for (var x = yield 1; c;) {} }\n", + true, + ), + ( + "switch-case-return-refuses", + "declare var x: any, y: any;\nfunction* g() { while (y) { switch (x) { case 1: return 42; } } }\n", + false, + ), + ( + "switch-multi-yield-case-test", + "declare var d: any, a: any, b: any, x: any;\nfunction* g() { switch (d) { case (yield a) + (yield b): x = 1; break; default: x = 2; } }\n", + true, + ), + ( + "computed-key-temps-are-var-at-es5", + "declare var x: any, k: any, o: any;\nfunction* g() { while (x) { var { [k]: v } = o; } }\n", + true, + ), + ("bare-yield-lowers", "function* g() { yield; }\n", true), + ( + "async-arrow-refusal-signals", + "declare var x: any, y: any, z: any;\nvar f = async () => { while (x) { if (y) { await z; } } };\n", + false, + ), + ]; + for (name, input, lowers) in snippets { + let output = emit_es5_clean(input); + let code = &javascript(&output).code; + let machine = code.contains("__generator(this,"); + let native = code.contains("function*"); + let diag = output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015); + assert_eq!( + machine, lowers, + "[{name}] expected lower={lowers}: machine={machine} native={native}\n{code}" + ); + if let Some(leak) = live_yield_leak(code) { + panic!("[{name}] live yield cloned into machine: {leak}"); + } + if let Some(leak) = raw_return_leak(code) { + panic!("[{name}] raw return in machine body drops value: {leak}"); + } + if !lowers { + assert!(native, "[{name}] refusal must keep the native form\n{code}"); + assert!(diag, "[{name}] refusal must signal TS-EMIT-1102\n{code}"); + } + } + } + + #[test] + fn sentinel_shapes_route_to_refusal_not_clone() { + // WD-2: eval has no accepting arms for these shapes; the sentinel + // count must send them there, never through the inline clone gate. + for (name, input) in [ + ( + "tagged-template", + "declare var tag: any, k: any;\nfunction* g() { while (tag`x ${yield k}`) { } }\n", + ), + ( + "type-assertion", + "declare var k: any;\nfunction* g() { while ((yield k)) { } }\n", + ), + ] { + let output = emit_es5_clean(input); + let code = &javascript(&output).code; + assert!( + code.contains("function* g(") + && output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "[{name}] sentinel shape must refuse with the native form + diagnostic:\n{code}" + ); + } + // JSX expressions need TypeScriptReact parsing; the same sentinel + // contract applies: refuse or lower, never a cloned live yield. + let jsx_input = "declare var k: any;\nfunction* g() { while (
{yield k}
) { } }\n"; + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScriptReact, + Arc::new(SourceText::new(jsx_input).expect("fits budget")), + )); + assert!(parsed.diagnostics().is_empty()); + let output = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + let code = &javascript(&output).code; + assert!( + code.contains("function* g(") || live_yield_leak(code).is_none(), + "jsx row must refuse or lower safely, got:\n{code}" + ); + } + + #[test] + fn machine_case_labels_are_contiguous_and_unique() { + // WD-3 + WD-6 + WD-7, boundaries 0/1/2/3 suspensions on both sides + // of every count. + for (name, input) in [ + ( + "zero", + "declare var x: any;\nasync function f() { while (x) { } }\n", + ), + ( + "one", + "declare var x: any, y: any;\nasync function f() { while (x) { await y; } }\n", + ), + ( + "two", + "declare var x: any, y: any, z: any;\nasync function f() { while (x) { await y; await z; } }\n", + ), + ( + "three-test-split", + "declare var o: any, k: any, y: any;\nasync function f() { while (o[await k]++ < 3) { await y; } }\n", + ), + ( + "import-two-suspensions", + "declare var a: any, b: any;\nasync function f() { return import(await a, { m: await b }); }\n", + ), + ( + "conditional-three", + "declare var a: any, b: any, c: any;\nasync function f() { return (await a) ? await b : await c; }\n", + ), + ( + "nested-yield-labels", + "declare var a: any;\nfunction* g() { while (yield (yield a)) {} }\n", + ), + ( + "for-var-init-split-labels", + "declare var x: any, c: any;\nfunction* g() { for (var x = yield 1; yield c;) { yield x; } }\n", + ), + ( + "jump-guard-exact-count", + "declare var x: any, y: any, z: any;\nasync function f() { while (x) { if (y) continue; await z; } }\n", + ), + ] { + let output = emit_es5_clean(input); + let code = &javascript(&output).code; + let machine_switch = code + .match_indices("switch (_") + .map(|(i, _)| i) + .find(|&i| code[i..].contains(".label)")); + if machine_switch.is_none() { + if !code.contains("__generator(this,") { + // Not lowered at all: the only honest alternative is a + // signalled refusal — a silent skip would hide a + // lowering regression. + assert!( + code.contains("function*") + && output.diagnostics.iter().any(|d| { + d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015 + }), + "[{name}] neither machine nor signalled refusal:\n{code}" + ); + } + // A degenerate zero-resume machine needs no dispatch + // switch; there are no labels to check. + continue; + } + let labels = machine_case_labels(code); + assert!(!labels.is_empty(), "[{name}] machine without labels"); + let expected: Vec = (0..=*labels.last().expect("non-empty")).collect(); + assert_eq!( + labels, expected, + "[{name}] case labels must be contiguous: {code}" + ); + } + } + + #[test] + fn nested_function_likes_own_suspensions_when_cloned() { + // WD-4: a method member owns its yields, so the object clones as + // clean into the machine and the INTERNAL yield stays legal. + // The method body holds a real suspension: ownership means the + // machine lowers with exactly one resume (the await) and the + // method's yield survives verbatim inside the cloned object — + // a walker that wrongly descended into the body would mint a + // second resume or refuse outright. + let output = emit_es5_clean( + "declare var x: any;\nasync function f() {\n while ({ *m() { yield 1; } }) { await x; }\n}\n", + ); + let code = &javascript(&output).code; + assert!(code.contains("__generator(this,"), "lowers: {code}"); + assert!( + code.contains("*m() {"), + "generator method cloned intact: {code}" + ); + assert!(code.contains("yield 1;"), "method owns its yield: {code}"); + assert_eq!( + code.matches(".sent()").count(), + 1, + "one machine resume: {code}" + ); + assert!( + !code.contains("function* ("), + "inner function not native: {code}" + ); + } + + #[test] + fn non_block_loop_bodies_refuse() { + // WD-5: single-statement bodies are outside the accepted set; the + // only valid outcomes are refusal with a diagnostic. + for (name, input) in [ + ( + "while", + "declare var x: any, y: any;\nasync function f() { while (x) await y; }\n", + ), + ( + "for", + "declare var x: any, y: any;\nasync function f() { for (; x;) await y; }\n", + ), + ( + "do", + "declare var x: any, y: any;\nasync function f() { do await y; while (x); }\n", + ), + ] { + let output = emit_es5_clean(input); + let code = &javascript(&output).code; + assert!( + !code.contains("__generator(this,") + && code.contains("function*") + && output + .diagnostics + .iter() + .any(|d| { d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015 }), + "[{name}] non-block body must refuse with the diagnostic, never lower or clone: {code}" + ); + } + } + + #[test] + fn labeled_nested_await_delegation_refuses_until_nested_if_slice() { + // WD-8: branch_awaits delegates through nested statement shapes. + let output = emit_es5_clean( + "declare var x: any, y: any, z: any;\nasync function f() {\n A: while (x) { if (y) { await z; break A; } }\n}\n", + ); + let code = &javascript(&output).code; + // Safety contract: refuse-or-lower, never a live await cloned + // outside the generator that owns it. (Full lowering of nested + // control flow inside labeled loop bodies is a banked machine + // slice; until then the refusal form is the correct output.) + assert!( + code.contains("function*") || code.contains("__generator(this,"), + "must refuse or lower: {code}" + ); + assert!(live_yield_leak(code).is_none(), "{code}"); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code() == transforms::codes::GENERATOR_REQUIRES_ES2015), + "refusal must signal: {:?}", + output.diagnostics + ); + } + + #[test] + fn deep_nesting_walkers_do_not_crash() { + // WD-9: the parser's nesting bound (MAX_DEPTH = 256) is the + // boundary, tested from both sides. Below it the fixture parses + // clean and the recursive walkers emit some form; above it the + // parser declines with the descriptive P010 diagnostic — never a + // crash, never a silent skip. (A `-` chain lexes as `--` decrement + // pairs and never parses; parens give real expression depth.) + let fixture = |depth: usize| { + format!( + "declare var y: any;\nasync function f() {{ {}await y{}; }}\n", + "(".repeat(depth), + ")".repeat(depth) + ) + }; + let parse = |input: String| { + crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(input.as_str()).expect("fits budget")), + )) + }; + let below = parse(fixture(56)); + assert!( + below.diagnostics().is_empty(), + "depth 56 must parse clean: {:?}", + below.diagnostics() + ); + let output = emit_output( + below.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + let code = &javascript(&output).code; + assert!( + code.contains("__generator(this,") || code.contains("function*"), + "some emitted form at depth 56: {code}" + ); + let above = parse(fixture(400)); + assert!( + !above.diagnostics().is_empty(), + "depth 400 must be rejected, not accepted" + ); + assert!( + above + .diagnostics() + .iter() + .any(|d| d.code().as_str().contains("P010")), + "rejection must be the descriptive nesting-bound diagnostic: {:?}", + above.diagnostics() + ); + } + + #[test] + fn es5_generator_import_source_materializes_before_options_yield() { + // A clean source operand with side effects must run before the + // options suspend: materialize into a temp in the pre-suspension + // segment, never reassemble it inside the resume where its effects + // would slip past the suspension. + let output = emit_es5_clean( + "declare var f: any, b: any;\nfunction* g() { return import(f(), { m: yield b }); }\n", + ); + let code = &javascript(&output).code; + let case0 = code + .split("case 0:") + .nth(1) + .and_then(|rest| rest.split("case 1:").next()) + .expect("two segments"); + let source_pos = case0.find("_a = f();").expect("source temp"); + let yield_pos = case0.find("/*yield*/").expect("split marker"); + assert!( + source_pos < yield_pos, + "source materializes before the yield: {code}" + ); + assert!( + code.contains("import(_a,"), + "resume reassembles from the temp: {code}" + ); + } + + #[test] + fn es5_async_nested_async_arrow_keeps_its_awaits() { + // A nested async arrow owns its awaits: the outer function's + // await-to-yield rewrite must stop at the nested body, and the + // nested arrow keeps its own `await` for its own lowering pass. + // (Await inside a NON-async function is invalid input the parser + // does not yet reject — banked as a checker slice.) + let output = emit_es5_clean( + "declare var k: any;\nasync function f() { var g = async () => { return await k; }; }\n", + ); + let code = &javascript(&output).code; + // The nested arrow lowers itself: its own __awaiter machine with + // the await in protocol form ([4 /*yield*/, k]) — no live yield. + assert_eq!( + code.matches("__awaiter").count(), + 2, + "both the outer function and the nested arrow lower: {code}" + ); + assert!(code.contains("/*yield*/, k"), "protocol form: {code}"); + assert!(live_yield_leak(code).is_none(), "{code}"); + } + + #[test] + fn machine_state_name_terminates_past_the_alphabet() { + // Declaring _a.._z plus _state exhausted the old allocator's fixed + // fallback and hung compilation forever. A regression must fail + // this test, not wedge the runner, so the emit runs on a thread + // with a hard timeout. + // The skip set is built from BODY statements, so the alphabet must + // be declared inside the generator, not as ambient `declare var`. + let input = "function* g() { var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _state; yield 1; }\n"; + let (tx, rx) = std::sync::mpsc::channel(); + let parsed_input = input.to_owned(); + std::thread::spawn(move || { + let parsed = crate::parser::parse(crate::scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(parsed_input.as_str()).expect("fits budget")), + )); + let output = emit_output( + parsed.product(), + &EmitOptions { + target: ScriptTarget::Es5, + no_emit_helpers: true, + ..EmitOptions::default() + }, + ); + let _ = tx.send(javascript(&output).code.clone()); + }); + let code = rx + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("allocator must terminate past the alphabet"); + assert!(code.contains("__generator(this,"), "{code}"); + } } diff --git a/crates/bamts-compiler/src/emitter/transforms.rs b/crates/bamts-compiler/src/emitter/transforms.rs index d3d7e393..5abf133f 100644 --- a/crates/bamts-compiler/src/emitter/transforms.rs +++ b/crates/bamts-compiler/src/emitter/transforms.rs @@ -97,6 +97,7 @@ pub enum LanguageFeature { LogicalAssignment, ClassFields, Using, + ForOf, } impl LanguageFeature { @@ -114,6 +115,7 @@ impl LanguageFeature { Self::LogicalAssignment => ScriptTarget::Es2021, Self::ClassFields => ScriptTarget::Es2022, Self::Using => ScriptTarget::EsNext, + Self::ForOf => ScriptTarget::Es2015, } } } @@ -1514,6 +1516,16 @@ impl<'a> Rewriter<'a> { "for-await-of requires ScriptTarget::Es2018 or later", ); } + // tsc's default ES5 for-of: index the iterable + // (`for (var _i = 0, src = it; _i < src.length; _i++)`). + // The binding moves into the body as a declaration, so + // destructuring bindings and defaults compose through + // the ordinary destructuring lowering. + if for_of.mode == ForOfMode::Sync + && Self::needs(LanguageFeature::ForOf, self.options) + { + return self.lower_sync_for_of(statement, for_of); + } let iterable = self.rewrite_expr(&for_of.iterable); let body = self.rewrite_single_statement(&for_of.body); vec![self.node( @@ -1527,12 +1539,24 @@ impl<'a> Rewriter<'a> { )] } Statement::ForIn(for_in) => { + // The for-in construct is native ES5; only the head's + // const/let binding needs var conversion there. + let binding = match &for_in.binding { + ForBinding::Variable(declaration) => { + let mut declaration = declaration.clone(); + if self.options.target <= ScriptTarget::Es5 { + declaration.kind = VariableKind::Var; + } + ForBinding::Variable(declaration) + } + target @ ForBinding::Target(_) => target.clone(), + }; let object = self.rewrite_expr(&for_in.object); let body = self.rewrite_single_statement(&for_in.body); vec![self.node( statement.range(), Statement::ForIn(ForInStatement { - binding: for_in.binding.clone(), + binding, object: Box::new(object), body: Box::new(body), }), @@ -1660,6 +1684,125 @@ impl<'a> Rewriter<'a> { self.node(block.range(), Block { statements }) } + /// tsc's default ES5 for-of lowering: `for (var _i = 0, src = IT; + /// _i < src.length; _i++) { BINDING = src[_i]; BODY }`. The binding + /// statement enters the body as a rewritten declaration so pattern + /// bindings and defaults compose with the destructuring lowering. + fn lower_sync_for_of(&mut self, statement: &Stmt, for_of: &ForOfStatement) -> Vec { + let range = statement.range(); + let counter = self.temp_ident(); + let source = self.temp_ident(); + let iterable = self.rewrite_expr(&for_of.iterable); + let zero = self.number_expr("0"); + let counter_decl = self.make_declarator(counter.clone(), Some(zero), range); + let counter_expr = self.node(counter.range(), Expression::Identifier(counter.clone())); + let length = self.member_ident(&source, "length", range); + let test = self.node( + range, + Expression::Binary(BinaryExpression { + operator: BinaryOperator::LessThan, + left: Box::new(counter_expr.clone()), + right: Box::new(length), + }), + ); + let counter_target = Box::new(self.node( + counter.range(), + AssignmentTarget::Identifier(counter.clone()), + )); + let update = self.node( + counter.range(), + Expression::Update(UpdateExpression { + operator: UpdateOperator::Increment, + argument: counter_target, + prefix: false, + }), + ); + // The body's binding declaration: source[counter]. + let element = self.member_computed(&source, &counter_expr, range); + let binding_statement = match &for_of.binding { + ForBinding::Variable(declaration) => { + let mut declaration = declaration.clone(); + declaration.kind = VariableKind::Var; + if declaration.declarations.len() == 1 { + let declarator = &declaration.declarations[0]; + let lowered = self.node( + declarator.range(), + VariableDeclarator { + initializer: Some(Box::new(element)), + ..declarator.data().clone() + }, + ); + declaration.declarations = vec![lowered]; + self.node(range, Statement::Variable(declaration)) + } else { + let body = self.rewrite_single_statement(&for_of.body); + return vec![self.node( + range, + Statement::ForOf(ForOfStatement { + mode: for_of.mode, + binding: ForBinding::Variable(declaration), + iterable: Box::new(iterable), + body: Box::new(body), + }), + )]; + } + } + // `target = source[counter];` - destructuring targets lower + // through the statement rewriter like any assignment. + ForBinding::Target(target) => { + let left = match target.data() { + AssignmentTarget::Member(member) => { + self.rewrite_member_target(member, target.range()) + } + _ => target.clone(), + }; + let assignment = self.node( + range, + Expression::Assignment(AssignmentExpression { + operator: AssignmentOperator::Assign, + left, + right: Box::new(element), + }), + ); + self.node( + range, + Statement::Expression(ExpressionStatement { + expression: Box::new(assignment), + }), + ) + } + }; + let source_decl = self.make_declarator(source.clone(), Some(iterable), range); + let initializer = ForInitializer::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations: vec![counter_decl, source_decl], + }); + // The binding declaration must itself lower (pattern bindings, + // defaults) - route it through the statement rewriter. + let lowered_binding = self.rewrite_statements(&[binding_statement]); + // A non-block body is a single statement, not a missing one: + // block_statements returns None for it, so the match form must + // push it rather than drop it. + let inner = self.rewrite_single_statement(&for_of.body); + let mut statements = lowered_binding; + match inner.data() { + Statement::Block(block) => statements.extend(block.data().statements.iter().cloned()), + _ => statements.push(inner.clone()), + } + let body_node = self.node(range, Block { statements }); + let body_stmt = self.node(range, Statement::Block(body_node)); + vec![self.node( + range, + Statement::For(ForStatement { + initializer: Some(initializer), + test: Some(Box::new(test)), + update: Some(Box::new(update)), + body: Box::new(body_stmt), + }), + )] + } + fn rewrite_variable_statement( &mut self, statement: &Stmt, @@ -1677,18 +1820,48 @@ impl<'a> Rewriter<'a> { ); } if !Self::needs(LanguageFeature::Destructuring, self.options) { - let declarations = declaration - .declarations - .iter() - .map(|declarator| self.rewrite_declarator_initializer(declarator)) - .collect(); - return vec![self.node( + let outer_key_prelude = std::mem::take(&mut self.key_prelude); + let mut statements = Vec::new(); + let mut declarations = Vec::with_capacity(declaration.declarations.len()); + for declarator in &declaration.declarations { + let rewritten = self.rewrite_declarator_initializer(declarator); + let prelude = std::mem::take(&mut self.key_prelude); + if !prelude.is_empty() { + if !declarations.is_empty() { + statements.push(self.node( + statement.range(), + Statement::Variable(VariableDeclaration { + range: declaration.range, + kind: declaration.kind, + declarations: std::mem::take(&mut declarations), + }), + )); + } + statements.extend(prelude); + } + declarations.push(rewritten); + } + self.key_prelude = outer_key_prelude; + statements.push(self.node( statement.range(), Statement::Variable(VariableDeclaration { + range: declaration.range, + kind: declaration.kind, declarations, - ..declaration.clone() }), - )]; + )); + return statements; + } + // A suspending binding (yield in a default or computed key) cannot + // live in a ternary: the machine must split the default selection + // into branch statements it owns, preserving conditional + // evaluation. Clean bindings lower to tsc's ternary declarators. + let suspending = declaration.declarations.iter().any(|declarator| { + count_binding_yields(&declarator.data().binding) > 0 + || binding_contains_await(&declarator.data().binding) + }); + if suspending { + return self.lower_suspending_declaration(declaration); } let mut declarations = Vec::new(); self.key_prelude.clear(); @@ -1712,6 +1885,32 @@ impl<'a> Rewriter<'a> { statements } + /// tsc's ES5 default shape: `name = read === void 0 ? DEFAULT : read`. + /// The default runs only when the read is undefined; the rewrite pass + /// owns await conversion inside it. + fn default_ternary(&mut self, read: &IdentifierNode, default: &Expr, range: TextRange) -> Expr { + let test_left = self.node(range, Expression::Identifier(read.clone())); + let void_zero = self.void_zero(range); + let test = self.node( + range, + Expression::Binary(BinaryExpression { + operator: BinaryOperator::StrictEqual, + left: Box::new(test_left), + right: Box::new(void_zero), + }), + ); + let consequent = self.rewrite_expr(default); + let alternate = self.node(range, Expression::Identifier(read.clone())); + self.node( + range, + Expression::Conditional(ConditionalExpression { + test: Box::new(test), + consequent: Box::new(consequent), + alternate: Box::new(alternate), + }), + ) + } + fn lower_declarator( &mut self, kind: VariableKind, @@ -1765,7 +1964,7 @@ impl<'a> Rewriter<'a> { let start = source.utf16_to_byte(range.start()).expect("binding start"); let end = source.utf16_to_byte(range.end()).expect("binding end"); let name = source.as_str()[start..end].to_owned(); - self.lower_class_expression(value, class, Some(&name)) + self.lower_class_expression(value, class, Some(&name), true) } else { self.rewrite_expr(value) }; @@ -1779,6 +1978,396 @@ impl<'a> Rewriter<'a> { }, ) } + /// Lowers a declaration whose binding carries suspensions (yield in + /// a default or computed key). Every bound name hoists first; reads + /// become temp var statements; a default becomes an if/else whose + /// arms assign the value temp, so the machine splits exactly at the + /// default's suspension and the default evaluates only when the + /// read is undefined — tsc's conditional-evaluation semantics in + /// this machine's statement protocol. + fn lower_suspending_declaration(&mut self, declaration: &VariableDeclaration) -> Vec { + let range = declaration.range; + let mut out = Vec::new(); + let mut names = Vec::new(); + for declarator in &declaration.declarations { + collect_binding_names(&declarator.data().binding, &mut names); + } + if !names.is_empty() { + let declarations = names + .into_iter() + .map(|name| self.make_declarator(name, None, range)) + .collect(); + let hoist = self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + ); + out.push(hoist); + } + for declarator in &declaration.declarations { + let initializer = declarator.data().initializer.as_deref(); + if let BindingPattern::Identifier(ident) = declarator.data().binding.data() { + // A plain identifier declarator alongside a suspending + // binding still needs its assignment; the rewrite pass + // owns the initializer's awaits. + if let Some(init) = initializer { + let value = self.rewrite_expr(init); + out.push(self.assign_statement(ident, value, range)); + } + continue; + } + let (rhs, needs_temp) = self.rhs_ident(initializer, range); + if needs_temp { + let init = initializer + .map(|value| self.rewrite_expr(value)) + .unwrap_or_else(|| self.ident_expr("undefined")); + let declarations = vec![self.make_declarator(rhs.clone(), Some(init), range)]; + let temp = self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + ); + out.push(temp); + } + self.emit_binding_statements(&rhs, &declarator.data().binding, range, &mut out); + } + out + } + + /// The statement form of one binding pattern: temp reads, default + /// if/else selections, and final assignments. Only runs for + /// suspending bindings; the clean form lives in the declarator + /// lowering. + fn emit_binding_statements( + &mut self, + rhs: &IdentifierNode, + pattern: &Pattern, + range: TextRange, + out: &mut Vec, + ) { + match pattern.data() { + BindingPattern::Identifier(_) | BindingPattern::Missing(_) => {} + BindingPattern::Rest(_) => {} + BindingPattern::Object(object) => { + let mut rest_keys: Vec = Vec::new(); + for property in &object.properties { + // The member expression this property reads. + let member = match &property.name { + PropertyName::Computed(key) => { + let temp = self.temp_ident(); + // The key temp needs its `var` even when the + // key suspends: the assignment form would + // otherwise target an undeclared name. + let temp_decl = vec![self.make_declarator(temp.clone(), None, range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations: temp_decl, + }), + )); + if contains_yield(key) { + let assign = + self.assign_statement(&temp, key.as_ref().clone(), range); + out.push(assign); + } else { + let value = self.rewrite_expr(key); + out.push(self.make_temp_declaration(temp.clone(), value, range)); + } + rest_keys.push(RestExcludeKey::Computed(temp.clone())); + let reference = + self.node(temp.range(), Expression::Identifier(temp.clone())); + self.member_computed(rhs, &reference, range) + } + _ => match property_key_text(self, property) { + Some(key) => { + rest_keys.push(RestExcludeKey::Static(key.clone())); + self.member_ident(rhs, &key, range) + } + None => continue, + }, + }; + self.emit_property_statements(property, member, range, rhs, out); + } + // The rest property binds after the named reads. + for property in &object.properties { + if let BindingPattern::Rest(rest) = property.binding.data() + && let BindingPattern::Identifier(ident) = rest.argument.data() + { + let excluded = self.rest_exclude_array(&rest_keys, range); + let call = self.rest_call(rhs, excluded, range); + out.push(self.assign_statement(ident, call, range)); + } + } + } + BindingPattern::Array(array) => { + let mut index = 0usize; + for element in &array.elements { + match element { + ArrayBindingElement::Elision | ArrayBindingElement::Missing(_) => { + index += 1; + } + ArrayBindingElement::Binding(binding) => { + if let BindingPattern::Rest(rest) = binding.data() + && let BindingPattern::Identifier(ident) = rest.argument.data() + { + // Rest consumes the remainder without + // advancing the element index. + let slice = self.slice_call(rhs, index, range); + out.push(self.assign_statement(ident, slice, range)); + continue; + } + self.emit_element_statements(rhs, binding, index, range, out); + index += 1; + } + } + } + } + BindingPattern::Assignment(assignment) => { + let member = self.member_index(rhs, 0, range); + self.emit_defaulted_binding( + &assignment.left, + &assignment.right, + member, + range, + out, + ); + } + } + } + + /// One object property's statement form: read temp, default if/else, + /// then the target bind or nested recursion. + fn emit_property_statements( + &mut self, + property: &ObjectBindingProperty, + member: Expr, + range: TextRange, + _rhs: &IdentifierNode, + out: &mut Vec, + ) { + match property.binding.data() { + BindingPattern::Identifier(ident) => { + let read = self.temp_ident(); + let declarations = vec![self.make_declarator(read.clone(), Some(member), range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + )); + let value = match &property.initializer { + Some(default) => { + let value = self.temp_ident(); + let declarations = vec![self.make_declarator(value.clone(), None, range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + )); + let selection = self.default_selection(&read, default, &value, range); + out.push(selection); + value + } + None => read, + }; + let value_expr = self.node(range, Expression::Identifier(value.clone())); + out.push(self.assign_statement(ident, value_expr, range)); + } + _ => { + let value = self.emit_read_with_default( + &property.binding, + &property.initializer, + member, + range, + out, + ); + self.emit_binding_statements(&value, &property.binding, range, out); + } + } + } + + fn emit_element_statements( + &mut self, + rhs: &IdentifierNode, + binding: &Pattern, + index: usize, + range: TextRange, + out: &mut Vec, + ) { + let member = self.member_index(rhs, index, range); + match binding.data() { + BindingPattern::Identifier(_) => { + let value = self.emit_read_with_default(binding, &None, member, range, out); + if let BindingPattern::Identifier(ident) = binding.data() { + let value_expr = self.node(range, Expression::Identifier(value.clone())); + out.push(self.assign_statement(ident, value_expr, range)); + } + } + BindingPattern::Assignment(assignment) => { + self.emit_defaulted_binding( + &assignment.left, + &assignment.right, + member, + range, + out, + ); + } + _ => { + let value = self.emit_read_with_default(binding, &None, member, range, out); + self.emit_binding_statements(&value, binding, range, out); + } + } + } + + /// The read temp for a member, plus the if/else default selection + /// when a default exists. Returns the identifier holding the value. + fn emit_read_with_default( + &mut self, + _binding: &Pattern, + initializer: &Option>, + member: Expr, + range: TextRange, + out: &mut Vec, + ) -> IdentifierNode { + let read = self.temp_ident(); + let declarations = vec![self.make_declarator(read.clone(), Some(member), range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + )); + match initializer { + Some(default) => { + let value = self.temp_ident(); + // The value temp needs its `var`: it is assigned only + // inside the selection's branches, so nothing else + // declares it and strict mode would throw on the + // implicit global. + let declarations = vec![self.make_declarator(value.clone(), None, range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + )); + let selection = self.default_selection(&read, default, &value, range); + out.push(selection); + value + } + None => read, + } + } + + /// `if (read === void 0) { value = DEFAULT; } else { value = read; }` + fn default_selection( + &mut self, + read: &IdentifierNode, + default: &Expr, + value: &IdentifierNode, + range: TextRange, + ) -> Stmt { + let test_left = self.node(range, Expression::Identifier(read.clone())); + let void_zero = self.void_zero(range); + let test = self.node( + range, + Expression::Binary(BinaryExpression { + operator: BinaryOperator::StrictEqual, + left: Box::new(test_left), + right: Box::new(void_zero), + }), + ); + let rewritten = self.rewrite_expr(default); + let then_assign = self.assign_statement(value, rewritten, range); + let then_block = self.node( + range, + Block { + statements: vec![then_assign], + }, + ); + let read_expr = self.node(range, Expression::Identifier(read.clone())); + let else_assign = self.assign_statement(value, read_expr, range); + let else_block = self.node( + range, + Block { + statements: vec![else_assign], + }, + ); + let then_stmt = self.node(range, Statement::Block(then_block)); + let else_stmt = self.node(range, Statement::Block(else_block)); + self.node( + range, + Statement::If(IfStatement { + test: Box::new(test), + consequent: Box::new(then_stmt), + alternate: Some(Box::new(else_stmt)), + }), + ) + } + + /// Binds a target pattern to a member read with a default: the + /// identifier case assigns the if/else value temp; nested patterns + /// recurse off the defaulted read. + fn emit_defaulted_binding( + &mut self, + left: &Pattern, + right: &Expr, + member: Expr, + range: TextRange, + out: &mut Vec, + ) { + match left.data() { + BindingPattern::Identifier(ident) => { + let read = self.temp_ident(); + let declarations = vec![self.make_declarator(read.clone(), Some(member), range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + )); + let value = self.temp_ident(); + let declarations = vec![self.make_declarator(value.clone(), None, range)]; + out.push(self.node( + range, + Statement::Variable(VariableDeclaration { + range, + kind: VariableKind::Var, + declarations, + }), + )); + let selection = self.default_selection(&read, right, &value, range); + out.push(selection); + let value_expr = self.node(range, Expression::Identifier(value.clone())); + out.push(self.assign_statement(ident, value_expr, range)); + } + _ => { + let boxed = Some(Box::new(right.clone())); + let value = self.emit_read_with_default(left, &boxed, member, range, out); + self.emit_binding_statements(&value, left, range, out); + } + } + } + fn lower_object_binding( &mut self, declarator: &VariableDeclaratorNode, @@ -1790,47 +2379,115 @@ impl<'a> Rewriter<'a> { let mut out = Vec::new(); if needs_temp { let init = initializer - .cloned() + .map(|value| self.rewrite_expr(value)) .unwrap_or_else(|| self.ident_expr("undefined")); out.push(self.make_declarator(rhs.clone(), Some(init), range)); } - let mut rest_names = Vec::new(); + self.lower_object_pattern(&rhs, object, range, &mut out); + out + } + + /// Lowers one object pattern against a source identifier, appending + /// declarators. Identifier bindings with defaults emit tsc's shape + /// (`var _t = o.a, a = _t === void 0 ? 1 : _t`); nested patterns + /// read off the defaulted temp (`{x: {y} = {}}` reads `y` off the + /// ternary result, so the default feeds the nested reads). + fn lower_object_pattern( + &mut self, + rhs: &IdentifierNode, + object: &ObjectBindingPattern, + range: TextRange, + out: &mut Vec, + ) -> Vec { + let mut rest_keys = Vec::new(); + // Properties evaluate in source order: a computed key runs after + // the reads and defaults of the properties before it, so its temp + // is a declarator in sequence rather than a hoisted prelude. for property in &object.properties { - let PropertyName::Computed(key) = &property.name else { - continue; - }; - let temp = self.temp_ident(); - let value = self.rewrite_expr(key); - let declaration = self.make_temp_declaration(temp.clone(), value, range); - self.key_prelude.push(declaration); - if let BindingPattern::Identifier(ident) = property.binding.data() { + if let PropertyName::Computed(key) = &property.name { + let temp = self.temp_ident(); + let value = self.rewrite_expr(key); + out.push(self.make_declarator(temp.clone(), Some(value), range)); + rest_keys.push(RestExcludeKey::Computed(temp.clone())); let reference = self.node(temp.range(), Expression::Identifier(temp.clone())); - let member = self.member_computed(&rhs, &reference, range); - out.push(self.make_declarator(ident.clone(), Some(member), range)); - } - } - for property in &object.properties { - if let PropertyName::Computed(_) = &property.name { + let member = self.member_computed(rhs, &reference, range); + self.lower_property_binding(property, member, range, out); continue; } if let BindingPattern::Rest(rest) = property.binding.data() { if let BindingPattern::Identifier(ident) = rest.argument.data() { - let excluded = self.rest_exclude_literal(&rest_names, range); - let call = self.rest_call(&rhs, excluded, range); + let excluded = self.rest_exclude_array(&rest_keys, range); + let call = self.rest_call(rhs, excluded, range); out.push(self.make_declarator(ident.clone(), Some(call), range)); } continue; } - if let BindingPattern::Identifier(ident) = property.binding.data() { - let Some(key) = property_key_text(self, property) else { - continue; - }; - rest_names.push(key.clone()); - let member = self.member_ident(&rhs, &key, range); - out.push(self.make_declarator(ident.clone(), Some(member), range)); + let Some(key) = property_key_text(self, property) else { + continue; + }; + rest_keys.push(RestExcludeKey::Static(key.clone())); + let member = self.member_ident(rhs, &key, range); + self.lower_property_binding(property, member, range, out); + } + rest_keys + } + + /// Binds one property's value expression: an identifier target with a + /// default emits the read-then-ternary pair; a nested pattern reads + /// through a temp (defaulted when a default exists); a bare + /// identifier clones the read. + fn lower_property_binding( + &mut self, + property: &ObjectBindingProperty, + member: Expr, + range: TextRange, + out: &mut Vec, + ) { + match property.binding.data() { + BindingPattern::Identifier(ident) => match &property.initializer { + Some(default) => { + let read = self.temp_ident(); + out.push(self.make_declarator(read.clone(), Some(member), range)); + let ternary = self.default_ternary(&read, default, range); + out.push(self.make_declarator(ident.clone(), Some(ternary), range)); + } + None => out.push(self.make_declarator(ident.clone(), Some(member), range)), + }, + BindingPattern::Object(nested) => { + let value = self.default_or_read(&property.initializer, member, range, out); + self.lower_object_pattern(&value, nested, range, out); + } + BindingPattern::Array(nested) => { + let value = self.default_or_read(&property.initializer, member, range, out); + self.lower_array_pattern(&value, nested, range, out); } + // Rest and invalid shapes never carry defaults; the caller's + // rest pass and parser refusals own them. + _ => {} + } + } + + /// A nested pattern's source: the read temp, or the defaulted temp + /// when a default exists (tsc reads nested bindings off the + /// ternary's value). + fn default_or_read( + &mut self, + initializer: &Option>, + member: Expr, + range: TextRange, + out: &mut Vec, + ) -> IdentifierNode { + let read = self.temp_ident(); + out.push(self.make_declarator(read.clone(), Some(member), range)); + match initializer { + Some(default) => { + let value = self.temp_ident(); + let ternary = self.default_ternary(&read, default, range); + out.push(self.make_declarator(value.clone(), Some(ternary), range)); + value + } + None => read, } - out } fn lower_array_binding( @@ -1844,32 +2501,90 @@ impl<'a> Rewriter<'a> { let mut out = Vec::new(); if needs_temp { let init = initializer - .cloned() + .map(|value| self.rewrite_expr(value)) .unwrap_or_else(|| self.ident_expr("undefined")); out.push(self.make_declarator(rhs.clone(), Some(init), range)); } + self.lower_array_pattern(&rhs, array, range, &mut out); + out + } + + /// Lowers one array pattern against a source identifier. Elements + /// with defaults (`[a = 1]`) emit the read-then-ternary pair; + /// `[a = 1]` under an assignment-wrapped nested pattern + /// (`[[d = 2] = []]`) defaults the element read before the nested + /// pattern consumes it. Holes advance the index without emitting. + fn lower_array_pattern( + &mut self, + rhs: &IdentifierNode, + array: &ArrayBindingPattern, + range: TextRange, + out: &mut Vec, + ) { let mut index = 0usize; for element in &array.elements { match element { - ArrayBindingElement::Elision => index += 1, + ArrayBindingElement::Elision | ArrayBindingElement::Missing(_) => index += 1, ArrayBindingElement::Binding(binding) => match binding.data() { BindingPattern::Identifier(ident) => { - let member = self.member_index(&rhs, index, range); + let member = self.member_index(rhs, index, range); out.push(self.make_declarator(ident.clone(), Some(member), range)); index += 1; } BindingPattern::Rest(rest) => { if let BindingPattern::Identifier(ident) = rest.argument.data() { - let slice = self.slice_call(&rhs, index, range); + let slice = self.slice_call(rhs, index, range); out.push(self.make_declarator(ident.clone(), Some(slice), range)); } } - _ => index += 1, + BindingPattern::Object(nested) => { + let member = self.member_index(rhs, index, range); + let value = self.default_or_read(&None, member, range, out); + self.lower_object_pattern(&value, nested, range, out); + index += 1; + } + BindingPattern::Array(nested) => { + let member = self.member_index(rhs, index, range); + let value = self.default_or_read(&None, member, range, out); + self.lower_array_pattern(&value, nested, range, out); + index += 1; + } + // `[a = 1]`: the default applies to the element read. + BindingPattern::Assignment(assignment) => { + let member = self.member_index(rhs, index, range); + match assignment.left.data() { + BindingPattern::Identifier(ident) => { + let read = self.temp_ident(); + out.push(self.make_declarator(read.clone(), Some(member), range)); + let ternary = self.default_ternary(&read, &assignment.right, range); + out.push(self.make_declarator(ident.clone(), Some(ternary), range)); + } + BindingPattern::Object(nested) => { + let value = self.default_or_read( + &Some(assignment.right.clone()), + member, + range, + out, + ); + self.lower_object_pattern(&value, nested, range, out); + } + BindingPattern::Array(nested) => { + let value = self.default_or_read( + &Some(assignment.right.clone()), + member, + range, + out, + ); + self.lower_array_pattern(&value, nested, range, out); + } + _ => {} + } + index += 1; + } + BindingPattern::Missing(_) => index += 1, }, - ArrayBindingElement::Missing(_) => index += 1, } } - out } fn make_declarator( @@ -2150,17 +2865,67 @@ impl<'a> Rewriter<'a> { ) } - fn rest_exclude_literal(&mut self, names: &[String], range: TextRange) -> Expr { + /// The `__rest` exclusion array. Static keys contribute plain + /// strings; computed keys contribute tsc's runtime coercion + /// (`typeof t === "symbol" ? t : t + ""`), because the excluded + /// property name is only known at runtime. + fn rest_exclude_array(&mut self, keys: &[RestExcludeKey], range: TextRange) -> Expr { let mut elements = Vec::new(); - for name in names { - let literal = self.string_literal(name); - let expr = self.node( - literal.range(), - Expression::Literal(Literal::String(literal)), - ); + for key in keys { + let expr = match key { + RestExcludeKey::Static(name) => { + let literal = self.string_literal(name); + self.node( + literal.range(), + Expression::Literal(Literal::String(literal)), + ) + } + RestExcludeKey::Computed(temp) => { + let temp_expr = self.node(temp.range(), Expression::Identifier(temp.clone())); + let symbol = self.string_literal("symbol"); + let symbol_expr = + self.node(symbol.range(), Expression::Literal(Literal::String(symbol))); + let typeof_argument = + self.node(temp.range(), Expression::Identifier(temp.clone())); + let typeof_expr = self.node( + temp.range(), + Expression::Unary(UnaryExpression { + operator: UnaryOperator::Typeof, + argument: Box::new(typeof_argument), + }), + ); + let test = self.node( + temp.range(), + Expression::Binary(BinaryExpression { + operator: BinaryOperator::StrictEqual, + left: Box::new(typeof_expr), + right: Box::new(symbol_expr), + }), + ); + let empty = self.string_literal(""); + let empty_expr = + self.node(empty.range(), Expression::Literal(Literal::String(empty))); + let concat_left = self.node(temp.range(), Expression::Identifier(temp.clone())); + let concat = self.node( + temp.range(), + Expression::Binary(BinaryExpression { + operator: BinaryOperator::Add, + left: Box::new(concat_left), + right: Box::new(empty_expr), + }), + ); + self.node( + temp.range(), + Expression::Conditional(ConditionalExpression { + test: Box::new(test), + consequent: Box::new(temp_expr), + alternate: Box::new(concat), + }), + ) + } + }; elements.push(ArrayElement::Expression(Box::new(expr))); } - let _ = range; self.node(range, Expression::Array(ArrayLiteral { elements })) } @@ -2473,7 +3238,14 @@ impl<'a> Rewriter<'a> { range, Statement::Variable(VariableDeclaration { range, - kind: VariableKind::Let, + // Temps can land inside machine bodies that clone + // verbatim; `let` there is a SyntaxError at ES5, but + // ES2015+ targets keep the tighter binding. + kind: if self.options.target >= ScriptTarget::Es2015 { + VariableKind::Let + } else { + VariableKind::Var + }, declarations: vec![declaration], }), ) @@ -3960,7 +4732,18 @@ impl<'a> Rewriter<'a> { body: Some(inner_body), }; let inner = if Self::needs(LanguageFeature::Generators, self.options) { - self.lower_generator_function(&inner) + let lowered = self.lower_generator_function(&inner); + if lowered.is_generator { + // The machine declined; the inner stays a native generator + // inside __awaiter, which the target cannot run. Signal it + // instead of emitting ES2015+ syntax silently. + self.diag( + codes::GENERATOR_REQUIRES_ES2015, + range, + "generators require ScriptTarget::Es2015 or later", + ); + } + lowered } else { inner }; @@ -4297,14 +5080,24 @@ impl<'a> Rewriter<'a> { ) -> Option<()> { let range = statement.range(); let cons_block = block_statements(&if_statement.consequent)?; - let alt_block = block_statements(if_statement.alternate.as_deref()?)?; + // A missing alternate is the empty else: fallthrough. Refusing + // the whole generator over `if (c) { ... }` with no else clause + // rejects one of the most common statement shapes. + let alt_block: &[Stmt] = match if_statement.alternate.as_deref() { + Some(alternate) => block_statements(alternate)?, + None => &[], + }; if !branch_is_simple(cons_block) || !branch_is_simple(alt_block) { return None; } let cons_resumes = count_branch_yields(cons_block); let alt_resumes = count_branch_yields(alt_block); - if cons_resumes == 0 && alt_resumes == 0 { + if cons_resumes == 0 + && alt_resumes == 0 + && !statements_contain_return(cons_block) + && !statements_contain_return(alt_block) + { // Inline reassembly: only the test can suspend. if !contains_yield(&if_statement.test) { ctx.push(statement.clone()); @@ -4370,7 +5163,9 @@ impl<'a> Rewriter<'a> { match body.data() { Statement::While(while_statement) => { let body_block = block_statements(&while_statement.body)?; - if count_yields(&while_statement.test) == 0 && count_branch_yields(body_block) == 0 + if count_yields(&while_statement.test) == 0 + && count_branch_yields(body_block) == 0 + && !statements_contain_return(body_block) { // Clean loops stay inline, label attached. ctx.push(statement.clone()); @@ -4380,7 +5175,10 @@ impl<'a> Rewriter<'a> { } Statement::DoWhile(do_statement) => { let body_block = block_statements(&do_statement.body)?; - if count_yields(&do_statement.test) == 0 && count_branch_yields(body_block) == 0 { + if count_yields(&do_statement.test) == 0 + && count_branch_yields(body_block) == 0 + && !statements_contain_return(body_block) + { ctx.push(statement.clone()); return Some(()); } @@ -4388,7 +5186,10 @@ impl<'a> Rewriter<'a> { } Statement::For(for_statement) => { let body_block = block_statements(&for_statement.body)?; - if for_clauses_are_clean(for_statement) && count_branch_yields(body_block) == 0 { + if for_clauses_are_clean(for_statement) + && count_branch_yields(body_block) == 0 + && !statements_contain_return(body_block) + { let rebuilt = self.inline_for_with_hoist(statement.range(), for_statement, ctx)?; let label = match statement.data() { @@ -4434,7 +5235,7 @@ impl<'a> Rewriter<'a> { let body_block = block_statements(&while_statement.body)?; let test_resumes = count_yields(&while_statement.test); let body_resumes = count_branch_yields(body_block); - if test_resumes == 0 && body_resumes == 0 { + if test_resumes == 0 && body_resumes == 0 && !statements_contain_return(body_block) { ctx.push(statement.clone()); return Some(()); } @@ -4495,7 +5296,7 @@ impl<'a> Rewriter<'a> { let body_block = block_statements(&do_statement.body)?; let test_resumes = count_yields(&do_statement.test); let body_resumes = count_branch_yields(body_block); - if test_resumes == 0 && body_resumes == 0 { + if test_resumes == 0 && body_resumes == 0 && !statements_contain_return(body_block) { ctx.push(statement.clone()); return Some(()); } @@ -4607,7 +5408,10 @@ impl<'a> Rewriter<'a> { ) -> Option<()> { let range = statement.range(); let body_block = block_statements(&for_statement.body)?; - if for_clauses_are_clean(for_statement) && count_branch_yields(body_block) == 0 { + if for_clauses_are_clean(for_statement) + && count_branch_yields(body_block) == 0 + && !statements_contain_return(body_block) + { let rebuilt = self.inline_for_with_hoist(range, for_statement, ctx)?; ctx.push(rebuilt); return Some(()); @@ -4621,7 +5425,18 @@ impl<'a> Rewriter<'a> { } let init_resumes = match &for_statement.initializer { Some(ForInitializer::Expression(expression)) => count_yields(expression), - Some(ForInitializer::Variable(_)) | None => 0, + Some(ForInitializer::Variable(declaration)) => declaration + .declarations + .iter() + .map(|declarator| { + declarator + .data() + .initializer + .as_deref() + .map_or(0, count_yields) + }) + .sum(), + None => 0, }; let test_resumes = count_yields(test); let update_resumes = for_statement.update.as_deref().map_or(0, count_yields); @@ -4728,11 +5543,15 @@ impl<'a> Rewriter<'a> { .cases .iter() .any(|case| case_body_resumes(case) > 0); - if !disc_suspends && !any_test && !any_body { + let any_return = switch_statement + .cases + .iter() + .any(|case| statements_contain_return(&case.data().consequent)); + if !disc_suspends && !any_test && !any_body && !any_return { ctx.push(statement.clone()); return Some(()); } - if disc_suspends && !any_test && !any_body { + if disc_suspends && !any_test && !any_body && !any_return { // Only the discriminant suspends: reassemble inline around // the resumed value, cases cloned. let discriminant = self.eval(&switch_statement.discriminant, ctx)?; @@ -4769,11 +5588,14 @@ impl<'a> Rewriter<'a> { } let head = ctx.segments.len() as u32 - 1; + // One resume segment per yield node, not per suspending case: a + // case test like `(yield a) + (yield b)` splits twice, and a + // case-count under-count skews every dispatch target past it. let suspending_tests = switch_statement .cases .iter() - .filter(|case| case_test_suspends(case)) - .count() as u32; + .map(|case| case.data().test.as_deref().map_or(0, count_yields)) + .sum::(); // Each suspending test's yield lands in the current segment and // its resume opens one new segment, so the dispatch phases span // head..head+suspending_tests and bodies start after. @@ -5125,8 +5947,15 @@ impl<'a> Rewriter<'a> { if yielded.delegate { return None; } - let argument = yielded.argument.as_ref()?; - let evaluated = self.eval(argument, ctx)?; + // A bare `yield;` is the same protocol with void 0; refusing + // the whole generator over the missing argument drops a + // trivial, common shape. + let void_argument = self.void_zero(expr.range()); + let argument = yielded + .argument + .as_ref() + .map_or(void_argument, |argument| argument.as_ref().clone()); + let evaluated = self.eval(&argument, ctx)?; let sentinel = self.number_expr("4 /*yield*/"); let array = self.array_literal(vec![sentinel, evaluated], range); let terminator = self.machine_return(array, range); @@ -5398,7 +6227,15 @@ impl<'a> Rewriter<'a> { )) } Expression::Import(import) => { - let source = self.eval(&import.source, ctx)?; + // Source operands run before the options suspend; a clean + // source with side effects must materialize into a temp + // ahead of the split, or its effects slip into the resume + // segment and run after the suspension. + let options_suspends = import.options.as_deref().is_some_and(contains_yield); + let source = match self.eval(&import.source, ctx)? { + value if options_suspends => self.materialize(value, ctx, range)?, + value => value, + }; let options = match &import.options { Some(options) if contains_yield(options) => { Some(Box::new(self.eval(options, ctx)?)) @@ -5701,6 +6538,7 @@ impl<'a> Rewriter<'a> { expression: &Expr, class_expression: &ClassExpression, inferred_name: Option<&str>, + hoist_suspending: bool, ) -> Expr { if matches!(FieldMode::for_options(self.options), FieldMode::Native) { let lowered = self.lower_class(&class_expression.class, expression.range(), None); @@ -5730,6 +6568,48 @@ impl<'a> Rewriter<'a> { child_marked, ); } + // A suspending computed key cannot live inside the IIFE: the + // arrow is a plain function, so a yield there is a SyntaxError + // (tsc 6.0.2 emits exactly that broken shape). Where the caller + // drains key_prelude into an enclosing statement list, the + // suspending key temps hoist out and the machine splits them; + // expression contexts cannot hoist, so they keep the IIFE and + // signal the requires-es2015 refusal instead of breaking + // silently. Static-field postludes reference the constructed + // temp and can never hoist; they refuse the same way. + // Whole-prelude move only: partitioning the prelude would + // reorder mixed suspending and clean keys, and computed keys + // evaluate in member order. Entries never reference the class + // temp (its declaration is pushed below), so the move is + // order-safe. An await counts too: in expression position the + // rewrite pass leaves it raw, and the machine entry gate then + // refuses the whole generator loudly. + let suspends = |statement: &Stmt| { + count_branch_yields(std::slice::from_ref(statement)) > 0 + || statements_contain_await(std::slice::from_ref(statement)) + }; + let refused = lowered.prelude.iter().any(&suspends) && !hoist_suspending; + if !refused && hoist_suspending && lowered.prelude.iter().any(&suspends) { + self.key_prelude + .extend(std::mem::take(&mut lowered.prelude)); + } + let postlude_refused = lowered.postlude.iter().any(suspends); + if refused || postlude_refused { + self.diag( + codes::GENERATOR_REQUIRES_ES2015, + expression.range(), + "generators require ScriptTarget::Es2015 or later", + ); + // Returning the unlowered class keeps the failure loud and + // honest: the machine's class walker (heritage, computed + // names, field initializers) sees the suspension, refuses + // the conversion, and the generator stays native under the + // diagnostic. Lowering anyway would bury a raw yield or + // await inside a synthesized plain arrow the machine + // cannot see through - the postlude (static-field + // initializers) included, not just the key prelude. + return expression.clone(); + } let class = self.syn_node( expression.range(), Expression::Class(ClassExpression { @@ -5741,7 +6621,11 @@ impl<'a> Rewriter<'a> { expression.range(), Statement::Variable(VariableDeclaration { range: expression.range(), - kind: VariableKind::Let, + kind: if self.options.target >= ScriptTarget::Es2015 { + VariableKind::Let + } else { + VariableKind::Var + }, declarations: vec![declaration], }), )); @@ -5815,15 +6699,34 @@ impl<'a> Rewriter<'a> { ); } match expression.data() { - Expression::Class(class) => self.lower_class_expression(expression, class, None), + Expression::Class(class) => { + let previous = self.replace_await; + self.replace_await = false; + let lowered = self.lower_class_expression(expression, class, None, false); + self.replace_await = previous; + lowered + } Expression::Function(function) => { + // A nested function-like owns its awaits: rewriting them + // into yields here would emit `yield` inside a non-async + // function, so the flag clears for the nested body and + // restores after. + let previous = self.replace_await; + self.replace_await = false; let function = self.rewrite_function_like(&function.function, expression.range()); + self.replace_await = previous; self.node( expression.range(), Expression::Function(FunctionExpression { function }), ) } - Expression::Arrow(arrow) => self.rewrite_arrow(expression, arrow), + Expression::Arrow(arrow) => { + let previous = self.replace_await; + self.replace_await = false; + let rewritten = self.rewrite_arrow(expression, arrow); + self.replace_await = previous; + rewritten + } Expression::Await(awaited) => { let argument = self.rewrite_expr(&awaited.argument); self.node( @@ -6059,20 +6962,7 @@ impl<'a> Rewriter<'a> { // Convert awaits inside the target too, so a // suspending target becomes a visible machine refusal // instead of a live `await` in ES5 output. - let object = self.rewrite_expr(&member.object); - let property = match &member.property { - MemberProperty::Computed(key) => { - MemberProperty::Computed(Box::new(self.rewrite_expr(key))) - } - other => other.clone(), - }; - self.node( - assignment.left.range(), - AssignmentTarget::Member(AssignmentMemberTarget { - object: Box::new(object), - property, - }), - ) + self.rewrite_member_target(member, assignment.left.range()) } else { assignment.left.clone() }; @@ -6096,6 +6986,27 @@ impl<'a> Rewriter<'a> { } } + fn rewrite_member_target( + &mut self, + member: &AssignmentMemberTarget, + range: TextRange, + ) -> AssignmentTargetNode { + let object = self.rewrite_expr(&member.object); + let property = match &member.property { + MemberProperty::Computed(key) => { + MemberProperty::Computed(Box::new(self.rewrite_expr(key))) + } + other => other.clone(), + }; + self.node( + range, + AssignmentTarget::Member(AssignmentMemberTarget { + object: Box::new(object), + property, + }), + ) + } + /// `Math.pow(base, exponent)` — the ES2016 exponentiation downlevel form. fn math_pow(&mut self, base: Expr, exponent: Expr, range: TextRange) -> Expr { let math = self.ident("Math"); @@ -6809,7 +7720,18 @@ impl<'a> Rewriter<'a> { body: Some(body), }; let inner = if Self::needs(LanguageFeature::Generators, self.options) { - self.lower_generator_function(&inner) + let lowered = self.lower_generator_function(&inner); + if lowered.is_generator { + // Same contract as the named-function path: the machine + // declined and the inner stays a native generator inside + // __awaiter, which the target cannot run — signal it. + self.diag( + codes::GENERATOR_REQUIRES_ES2015, + expression.range(), + "generators require ScriptTarget::Es2015 or later", + ); + } + lowered } else { inner }; @@ -6958,7 +7880,17 @@ fn block_statements(statement: &Stmt) -> Option<&[Stmt]> { fn for_clauses_are_clean(for_statement: &ForStatement) -> bool { let init_clean = match &for_statement.initializer { Some(ForInitializer::Expression(expression)) => !contains_yield(expression), - _ => true, + Some(ForInitializer::Variable(declaration)) => { + declaration.declarations.iter().all(|declarator| { + declarator + .data() + .initializer + .as_deref() + .is_none_or(|init| !contains_yield(init)) + && count_binding_yields(&declarator.data().binding) == 0 + }) + } + None => true, }; let test_clean = for_statement .test @@ -6971,6 +7903,92 @@ fn for_clauses_are_clean(for_statement: &ForStatement) -> bool { init_clean && test_clean && update_clean } +/// One `__rest` exclusion entry: a static property name or the temp +/// holding an evaluated computed key (excluded at runtime via tsc's +/// symbol-aware coercion). +enum RestExcludeKey { + Static(String), + Computed(IdentifierNode), +} + +/// Suspensions inside a binding pattern: default initializers and +/// computed key expressions. Destructuring lowering erases nothing once +/// these count — a clean pattern lowers to ternary declarators, a +/// suspending one to machine-owned branch statements. +fn count_binding_yields(pattern: &Pattern) -> u32 { + match pattern.data() { + BindingPattern::Identifier(_) | BindingPattern::Missing(_) => 0, + BindingPattern::Object(object) => object + .properties + .iter() + .map(|property| { + let key = match &property.name { + PropertyName::Computed(key) => count_yields(key), + _ => 0, + }; + let default = property.initializer.as_deref().map_or(0, count_yields); + key + default + count_binding_yields(&property.binding) + }) + .sum(), + BindingPattern::Array(array) => array + .elements + .iter() + .map(|element| match element { + ArrayBindingElement::Elision | ArrayBindingElement::Missing(_) => 0, + ArrayBindingElement::Binding(binding) => count_binding_yields(binding), + }) + .sum(), + BindingPattern::Rest(rest) => count_binding_yields(&rest.argument), + BindingPattern::Assignment(assignment) => { + count_yields(&assignment.right) + count_binding_yields(&assignment.left) + } + } +} + +/// Every identifier a pattern binds, in source order. +fn collect_binding_names(pattern: &Pattern, names: &mut Vec) { + match pattern.data() { + BindingPattern::Identifier(ident) => names.push(ident.clone()), + BindingPattern::Missing(_) => {} + BindingPattern::Object(object) => { + for property in &object.properties { + collect_binding_names(&property.binding, names); + } + } + BindingPattern::Array(array) => { + for element in &array.elements { + if let ArrayBindingElement::Binding(binding) = element { + collect_binding_names(binding, names); + } + } + } + BindingPattern::Rest(rest) => collect_binding_names(&rest.argument, names), + BindingPattern::Assignment(assignment) => { + collect_binding_names(&assignment.left, names); + } + } +} + +/// The await view of the same binding-pattern walk. +fn binding_contains_await(pattern: &Pattern) -> bool { + match pattern.data() { + BindingPattern::Identifier(_) | BindingPattern::Missing(_) => false, + BindingPattern::Object(object) => object.properties.iter().any(|property| { + matches!(&property.name, PropertyName::Computed(key) if contains_await(key)) + || property.initializer.as_deref().is_some_and(contains_await) + || binding_contains_await(&property.binding) + }), + BindingPattern::Array(array) => array.elements.iter().any(|element| match element { + ArrayBindingElement::Elision | ArrayBindingElement::Missing(_) => false, + ArrayBindingElement::Binding(binding) => binding_contains_await(binding), + }), + BindingPattern::Rest(rest) => binding_contains_await(&rest.argument), + BindingPattern::Assignment(assignment) => { + contains_await(&assignment.right) || binding_contains_await(&assignment.left) + } + } +} + /// Whether every branch statement is a shape the labeled if slice emits. fn branch_is_simple(statements: &[Stmt]) -> bool { statements.iter().all(|statement| { @@ -6981,7 +7999,10 @@ fn branch_is_simple(statements: &[Stmt]) -> bool { }) } -/// The number of yield splits a branch's statements produce. +/// The number of yield splits a branch's statements produce. Zero gates +/// the inline verbatim clone, so a statement shape the loop-body emitter +/// cannot lower must report non-zero (it refuses, keeping the native +/// form) — never zero. fn count_branch_yields(statements: &[Stmt]) -> u32 { statements .iter() @@ -6996,13 +8017,79 @@ fn count_branch_yields(statements: &[Stmt]) -> u32 { .initializer .as_deref() .map_or(0, count_yields) + + count_binding_yields(&declarator.data().binding) }) .sum(), - _ => 0, + Statement::Continue(_) | Statement::Break(_) | Statement::Empty => 0, + Statement::Return(ret) => ret.argument.as_deref().map_or(0, count_yields), + Statement::Throw(throw) => count_yields(&throw.argument), + Statement::Block(block) => count_branch_yields(&block.data().statements), + // Clean nested control flow clones correctly inside a machine + // body, so it counts precisely; suspending nested shapes route + // to the body emitter, which refuses what it cannot lower. + Statement::If(if_statement) => { + count_yields(&if_statement.test) + + count_branch_yields(branch_statements(&if_statement.consequent)) + + if_statement + .alternate + .as_ref() + .map_or(0, |alt| count_branch_yields(branch_statements(alt))) + } + Statement::While(while_statement) => { + count_yields(&while_statement.test) + + count_branch_yields(branch_statements(&while_statement.body)) + } + Statement::DoWhile(do_statement) => { + count_yields(&do_statement.test) + + count_branch_yields(branch_statements(&do_statement.body)) + } + Statement::Labeled(labeled) => count_branch_yields(branch_statements(&labeled.body)), + _ => 1, }) .sum() } +/// The statements of a branch or loop body: a block flattens, any other +/// statement is a single-statement branch. Counters and the return guard +/// must see through non-block bodies, or a nested `while (x) return z;` +/// hides its return and the clone gate drops the value. +fn branch_statements(statement: &Stmt) -> &[Stmt] { + match statement.data() { + Statement::Block(block) => &block.data().statements, + _ => std::slice::from_ref(statement), + } +} + +/// Whether any statement in this region returns — at any nesting depth, +/// but never inside a nested function-like (those own their returns). +/// A cloned raw return exits the machine's inner function directly and +/// the runtime silently drops the value, so clone gates must refuse. +fn statements_contain_return(statements: &[Stmt]) -> bool { + statements.iter().any(|statement| match statement.data() { + Statement::Return(_) => true, + Statement::Block(block) => statements_contain_return(&block.data().statements), + Statement::If(if_statement) => { + statements_contain_return(branch_statements(&if_statement.consequent)) + || if_statement + .alternate + .as_ref() + .is_some_and(|alt| statements_contain_return(branch_statements(alt))) + } + Statement::While(while_statement) => { + statements_contain_return(branch_statements(&while_statement.body)) + } + Statement::DoWhile(do_statement) => { + statements_contain_return(branch_statements(&do_statement.body)) + } + Statement::Labeled(labeled) => statements_contain_return(branch_statements(&labeled.body)), + Statement::Switch(switch_statement) => switch_statement + .cases + .iter() + .any(|case| statements_contain_return(&case.data().consequent)), + _ => false, + }) +} + impl ChainSegment { const fn optional(&self) -> bool { match self { @@ -7011,14 +8098,112 @@ impl ChainSegment { } } +/// The enclosing-context code of a class: decorators, the heritage +/// expression, computed member names, and field initializers. Method, +/// constructor, and static-block bodies are function boundaries and own +/// their suspensions, so they stay opaque to the enclosing walkers. +fn count_class_context_yields(class: &ClassDeclaration) -> u32 { + let decorators: u32 = class + .decorators + .iter() + .map(|decorator| count_yields(&decorator.data().expression)) + .sum(); + let heritage = class + .extends + .as_ref() + .map(|heritage| count_yields(&heritage.expression)) + .unwrap_or(0); + let members: u32 = class + .members + .iter() + .map(|member| match member.data() { + ClassMember::Method(method) => match &method.name { + PropertyName::Computed(key) => count_yields(key), + _ => 0, + }, + ClassMember::Property(property) => match &property.name { + PropertyName::Computed(key) => count_yields(key), + _ => property.initializer.as_deref().map_or(0, count_yields), + }, + ClassMember::AutoAccessor(accessor) => match &accessor.name { + PropertyName::Computed(key) => count_yields(key), + _ => accessor.initializer.as_deref().map_or(0, count_yields), + }, + _ => 0, + }) + .sum(); + decorators + heritage + members +} + +/// The await view of the same class-context walk. +fn class_context_contains_await(class: &ClassDeclaration) -> bool { + if class + .decorators + .iter() + .any(|decorator| contains_await(&decorator.data().expression)) + || class + .extends + .as_ref() + .is_some_and(|heritage| contains_await(&heritage.expression)) + { + return true; + } + class.members.iter().any(|member| match member.data() { + ClassMember::Method(method) => { + matches!(&method.name, PropertyName::Computed(key) if contains_await(key)) + } + ClassMember::Property(property) => { + matches!(&property.name, PropertyName::Computed(key) if contains_await(key)) + || property.initializer.as_deref().is_some_and(contains_await) + } + ClassMember::AutoAccessor(accessor) => { + matches!(&accessor.name, PropertyName::Computed(key) if contains_await(key)) + || accessor.initializer.as_deref().is_some_and(contains_await) + } + _ => false, + }) +} + +/// The yield view of the same class-context walk. +fn class_context_contains_yield(class: &ClassDeclaration) -> bool { + if class + .decorators + .iter() + .any(|decorator| contains_yield(&decorator.data().expression)) + || class + .extends + .as_ref() + .is_some_and(|heritage| contains_yield(&heritage.expression)) + { + return true; + } + class.members.iter().any(|member| match member.data() { + ClassMember::Method(method) => { + matches!(&method.name, PropertyName::Computed(key) if contains_yield(key)) + } + ClassMember::Property(property) => { + matches!(&property.name, PropertyName::Computed(key) if contains_yield(key)) + || property.initializer.as_deref().is_some_and(contains_yield) + } + ClassMember::AutoAccessor(accessor) => { + matches!(&accessor.name, PropertyName::Computed(key) if contains_yield(key)) + || accessor.initializer.as_deref().is_some_and(contains_yield) + } + _ => false, + }) +} + /// Counts the yield nodes of this function's body (nested function-likes /// own their own yields). fn count_yields(expression: &Expr) -> u32 { match expression.data() { - Expression::Yield(_) => 1, + Expression::Yield(yielded) => 1 + yielded.argument.as_deref().map_or(0, count_yields), Expression::Identifier(_) | Expression::This | Expression::Super => 0, Expression::Literal(_) | Expression::Meta(_) | Expression::Missing(_) => 0, - Expression::Function(_) | Expression::Class(_) | Expression::Arrow(_) => 0, + Expression::Function(_) | Expression::Arrow(_) => 0, + // Heritage, computed names, and field initializers run in the + // enclosing context; bodies stay owned. + Expression::Class(class) => count_class_context_yields(&class.class), Expression::Template(template) => template.expressions.iter().map(count_yields).sum(), Expression::Array(array) => array .elements @@ -7040,6 +8225,17 @@ fn count_yields(expression: &Expr) -> u32 { _ => 0, } } + // A spread element carries an expression; method-like + // members (methods/getters/setters) own their own + // suspensions, just like nested function-likes, so the + // wildcard below is safe for them. + ObjectMember::Spread(spread) => count_yields(&spread.argument), + // A method's computed name executes in the enclosing + // function; its body owns its own suspensions. + ObjectMember::Method(method) => match &method.name { + PropertyName::Computed(key) => count_yields(key), + _ => 0, + }, _ => 0, }) .sum(), @@ -7050,6 +8246,7 @@ fn count_yields(expression: &Expr) -> u32 { .iter() .map(|argument| match argument { CallArgument::Expression(value) => count_yields(value), + CallArgument::Spread(spread) => count_yields(&spread.argument), _ => 0, }) .sum::() @@ -7068,12 +8265,27 @@ fn count_yields(expression: &Expr) -> u32 { .iter() .map(|argument| match argument { CallArgument::Expression(value) => count_yields(value), + CallArgument::Spread(spread) => count_yields(&spread.argument), _ => 0, }) .sum::() } Expression::Await(awaited) => count_yields(&awaited.argument), Expression::Unary(unary) => count_yields(&unary.argument), + Expression::Update(update) => match update.argument.data() { + AssignmentTarget::Member(member) => { + count_yields(&member.object) + + match &member.property { + MemberProperty::Computed(key) => count_yields(key), + _ => 0, + } + } + // Pattern and invalid targets can carry yields in computed + // keys and defaults; a sentinel count routes them to eval, + // which refuses, rather than the inline verbatim clone. + AssignmentTarget::Identifier(_) => 0, + _ => 1, + }, Expression::Binary(binary) => count_yields(&binary.left) + count_yields(&binary.right), Expression::Logical(logical) => count_yields(&logical.left) + count_yields(&logical.right), Expression::Conditional(conditional) => { @@ -7091,14 +8303,24 @@ fn count_yields(expression: &Expr) -> u32 { _ => 0, } } - _ => 0, + // Pattern and invalid targets can carry yields; a + // sentinel count routes them to eval, which refuses, + // rather than the inline verbatim clone. + AssignmentTarget::Identifier(_) => 0, + _ => 1, } } Expression::Sequence(sequence) => sequence.expressions.iter().map(count_yields).sum(), Expression::Parenthesized(inner) => count_yields(inner), Expression::As(cast) => count_yields(&cast.expression), Expression::NonNull(non_null) => count_yields(&non_null.expression), - _ => 0, + Expression::Import(import) => { + count_yields(&import.source) + import.options.as_deref().map_or(0, count_yields) + } + // Unknown shapes report non-zero: zero means provably clean and + // gates the inline verbatim clone; unknown routes to eval, whose + // refusal keeps the native form. + _ => 1, } } @@ -7263,7 +8485,12 @@ fn machine_state_name(skip: &std::collections::HashSet, temps: &[String] let taken: std::collections::HashSet<&String> = skip.iter().chain(temps.iter()).collect(); let mut index = 0u32; loop { - let name = machine_name(index).unwrap_or_else(|| "_state".to_string()); + // Past the alphabet machine_name returns None forever; a fixed + // "_state" fallback would then loop endlessly when _state is also + // taken, so the suffix keeps every candidate fresh and the loop + // terminates. + let name = + machine_name(index).unwrap_or_else(|| format!("_state{}", index.saturating_sub(26))); if !taken.contains(&name) { return name; } @@ -7272,7 +8499,11 @@ fn machine_state_name(skip: &std::collections::HashSet, temps: &[String] } fn call_argument_suspends(argument: &CallArgument) -> bool { - matches!(argument, CallArgument::Expression(value) if contains_yield(value)) + match argument { + CallArgument::Expression(value) => contains_yield(value), + CallArgument::Spread(spread) => contains_yield(&spread.argument), + _ => false, + } } /// Whether an assignment target subtree still holds an await, so the @@ -7292,7 +8523,10 @@ fn contains_await(expression: &Expr) -> bool { Expression::Await(_) => true, Expression::Identifier(_) | Expression::This | Expression::Super => false, Expression::Literal(_) | Expression::Meta(_) | Expression::Missing(_) => false, - Expression::Function(_) | Expression::Class(_) | Expression::Arrow(_) => false, + Expression::Function(_) | Expression::Arrow(_) => false, + // Heritage, computed names, and field initializers run in the + // enclosing context; bodies stay owned. + Expression::Class(class) => class_context_contains_await(&class.class), Expression::Yield(yielded) => yielded.argument.as_deref().is_some_and(contains_await), Expression::Template(template) => template.expressions.iter().any(contains_await), Expression::Array(array) => array.elements.iter().any(|element| match element { @@ -7305,27 +8539,45 @@ fn contains_await(expression: &Expr) -> bool { contains_await(&property.value) || matches!(&property.name, PropertyName::Computed(key) if contains_await(key)) } - _ => true, + ObjectMember::Spread(spread) => contains_await(&spread.argument), + // A method's computed name executes in the enclosing function; + // its body owns its own suspensions. + ObjectMember::Method(method) => { + matches!(&method.name, PropertyName::Computed(key) if contains_await(key)) + } + _ => false, }), Expression::Call(call) => { contains_await(&call.callee) || call.arguments.iter().any(|argument| match argument { CallArgument::Expression(value) => contains_await(value), + CallArgument::Spread(spread) => contains_await(&spread.argument), _ => false, }) } - Expression::Member(member) => { - contains_await(&member.object) - || matches!(&member.property, MemberProperty::Computed(key) if contains_await(key)) - } + Expression::Member(member) => target_contains_await(&member.object, &member.property), Expression::New(new) => { contains_await(&new.callee) || new.arguments.iter().any(|argument| match argument { CallArgument::Expression(value) => contains_await(value), + CallArgument::Spread(spread) => contains_await(&spread.argument), _ => false, }) } Expression::Unary(unary) => contains_await(&unary.argument), + Expression::Import(import) => { + contains_await(&import.source) || import.options.as_deref().is_some_and(contains_await) + } + Expression::Update(update) => match update.argument.data() { + AssignmentTarget::Member(member) => { + target_contains_await(&member.object, &member.property) + } + // Pattern and invalid targets can carry expressions in + // computed keys and defaults; report containment so the + // machine refuses rather than clones. + AssignmentTarget::Identifier(_) => false, + _ => true, + }, Expression::Binary(binary) => contains_await(&binary.left) || contains_await(&binary.right), Expression::Logical(logical) => { contains_await(&logical.left) || contains_await(&logical.right) @@ -7337,8 +8589,16 @@ fn contains_await(expression: &Expr) -> bool { } Expression::Assignment(assignment) => { contains_await(&assignment.right) - || matches!(assignment.left.data(), AssignmentTarget::Member(member) - if contains_await(&member.object)) + || match assignment.left.data() { + AssignmentTarget::Member(member) => { + target_contains_await(&member.object, &member.property) + } + // Pattern and invalid targets can carry expressions in + // computed keys and defaults; report containment so the + // machine refuses rather than clones. + AssignmentTarget::Identifier(_) => false, + _ => true, + } } Expression::Sequence(sequence) => sequence.expressions.iter().any(contains_await), Expression::Parenthesized(inner) => contains_await(inner), @@ -7360,6 +8620,7 @@ fn statements_contain_await(statements: &[Stmt]) -> bool { .initializer .as_deref() .is_some_and(contains_await) + || binding_contains_await(&declarator.data().binding) }), Statement::Return(returned) => returned.argument.as_deref().is_some_and(contains_await), Statement::Block(block) => statements_contain_await(&block.data().statements), @@ -7377,7 +8638,17 @@ fn statements_contain_await(statements: &[Stmt]) -> bool { Statement::For(for_statement) => { let init = match &for_statement.initializer { Some(ForInitializer::Expression(expression)) => contains_await(expression), - _ => false, + Some(ForInitializer::Variable(declaration)) => { + declaration.declarations.iter().any(|declarator| { + declarator + .data() + .initializer + .as_deref() + .is_some_and(contains_await) + || binding_contains_await(&declarator.data().binding) + }) + } + None => false, }; let test = for_statement.test.as_deref().is_some_and(contains_await); let update = for_statement.update.as_deref().is_some_and(contains_await); @@ -7393,6 +8664,9 @@ fn statements_contain_await(statements: &[Stmt]) -> bool { Statement::Labeled(labeled) => branch_awaits(&labeled.body), // Jumps and empties hold no expressions. Statement::Break(_) | Statement::Continue(_) | Statement::Empty => false, + // The machine helper wraps the body in try/catch, so a cloned raw + // throw is protocol-safe; only its argument's awaits matter. + Statement::Throw(throw_statement) => contains_await(&throw_statement.argument), _ => true, }) } @@ -7409,10 +8683,11 @@ fn branch_awaits(statement: &Stmt) -> bool { } fn contains_yield_array(array: &ArrayLiteral) -> bool { - array - .elements - .iter() - .any(|element| matches!(element, ArrayElement::Expression(value) if contains_yield(value))) + array.elements.iter().any(|element| match element { + ArrayElement::Expression(value) => contains_yield(value), + ArrayElement::Spread(spread) => contains_yield(&spread.argument), + _ => false, + }) } fn contains_yield(expression: &Expr) -> bool { @@ -7421,25 +8696,31 @@ fn contains_yield(expression: &Expr) -> bool { Expression::Identifier(_) | Expression::This | Expression::Super => false, Expression::Literal(_) | Expression::Meta(_) | Expression::Missing(_) => false, // A yield inside a nested function belongs to that function. - Expression::Function(_) | Expression::Class(_) | Expression::Arrow(_) => false, + Expression::Function(_) | Expression::Arrow(_) => false, + // Heritage, computed names, and field initializers run in the + // enclosing context; bodies stay owned. + Expression::Class(class) => class_context_contains_yield(&class.class), Expression::Template(template) => template.expressions.iter().any(contains_yield), Expression::TaggedTemplate(_) => true, - Expression::Array(array) => array.elements.iter().any(|element| match element { - ArrayElement::Expression(nested) => contains_yield(nested), - ArrayElement::Spread(spread) => contains_yield(&spread.argument), - _ => false, - }), + Expression::Array(array) => contains_yield_array(array), Expression::Object(object) => object.members.iter().any(|member| match member.data() { ObjectMember::Property(property) => { contains_yield(&property.value) || matches!(&property.name, PropertyName::Computed(key) if contains_yield(key)) } - _ => true, + ObjectMember::Spread(spread) => contains_yield(&spread.argument), + // A method's computed name executes in the enclosing function; + // its body owns its own suspensions. + ObjectMember::Method(method) => { + matches!(&method.name, PropertyName::Computed(key) if contains_yield(key)) + } + _ => false, }), Expression::Call(call) => { contains_yield(&call.callee) || call.arguments.iter().any(|argument| match argument { CallArgument::Expression(nested) => contains_yield(nested), + CallArgument::Spread(spread) => contains_yield(&spread.argument), _ => false, }) } @@ -7451,14 +8732,22 @@ fn contains_yield(expression: &Expr) -> bool { contains_yield(&new.callee) || new.arguments.iter().any(|argument| match argument { CallArgument::Expression(nested) => contains_yield(nested), + CallArgument::Spread(spread) => contains_yield(&spread.argument), _ => false, }) } Expression::Await(awaited) => contains_yield(&awaited.argument), Expression::Unary(unary) => contains_yield(&unary.argument), Expression::Update(update) => match update.argument.data() { - AssignmentTarget::Member(member) => contains_yield(&member.object), - _ => false, + AssignmentTarget::Member(member) => { + contains_yield(&member.object) + || matches!(&member.property, MemberProperty::Computed(key) if contains_yield(key)) + } + // Pattern and invalid targets can carry expressions in + // computed keys and defaults; report containment so the + // machine refuses rather than clones. + AssignmentTarget::Identifier(_) => false, + _ => true, }, Expression::Binary(binary) => contains_yield(&binary.left) || contains_yield(&binary.right), Expression::Logical(logical) => { @@ -8708,6 +9997,21 @@ mod tests { assert!(!code.contains("static x"), "{code}"); } + #[test] + fn native_declaration_emits_suspending_class_key_prelude_in_order() { + let output = emit_at( + "function* g() { const left = before(), C = class { [yield key()]() {} }, right = after(); }\n", + ScriptTarget::Es2015, + ); + assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics); + let code = javascript(&output); + let positions = ["before()", "key()", "C =", "after()"].map(|needle| { + assert_eq!(code.matches(needle).count(), 1, "{needle}: {code}"); + code.find(needle).expect("emitted expression") + }); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{code}"); + } + #[test] fn static_this_and_super_are_rebound_to_the_constructor() { let output = emit_at( @@ -9146,16 +10450,24 @@ console.log(JSON.stringify([bar, bar4, log])); ScriptTarget::Es5, ); let code = javascript(&output); - let key_temp_at = code.find("let _t").expect("key temp declared"); - let digits: String = code[key_temp_at + "let _t".len()..] + // ES5 temps are `var` (a `let` inside cloned machine bodies is a + // SyntaxError at ES5); ES2015+ keeps the tighter `let` binding. + assert!(code.contains("var _t"), "ES5 temp kind: {code}"); + assert!(!code.contains("let _t"), "ES5 temp kind: {code}"); + let key_read_at = code.find(" = key").expect("key temp declared"); + let temp_name: String = code[..key_read_at] .chars() - .take_while(|character| character.is_ascii_digit()) + .rev() + .take_while(|character| character.is_ascii_alphanumeric() || *character == '_') + .collect::>() + .into_iter() + .rev() .collect(); - let temp_name = format!("_t{digits}"); - let declaration_form = format!("let {temp_name} = key;"); - assert!( - code.contains(&declaration_form), - "key evaluates once into a temp ({declaration_form:?}): {code}" + assert!(temp_name.starts_with("_t"), "key temp name: {code}"); + assert_eq!( + code.matches(" = key").count(), + 1, + "key evaluates once into a temp: {code}" ); assert!( code.contains(&format!("[{temp_name}]")), @@ -9262,6 +10574,76 @@ console.log(JSON.stringify([bar, bar4, log])); ); } + /// ES5 for-of lowers to the index form and still loops: the + /// binding lands inside the body, the index bound is the iterable + /// length, and one pass collects every element. + #[test] + fn node_executes_lowered_for_of_collects_every_element() { + let output = emit_at( + "var out = [];\nfor (const k of [1, 2, 3]) { out.push(k); }\nfor (const k in { a: 1 }) { out.push(k); }\nconsole.log(out.join(\",\"));\n", + ScriptTarget::Es5, + ); + let code = javascript(&output); + assert!(!code.contains("of "), "for-of must lower at es5:\n{code}"); + assert!( + code.contains("var k in"), + "for-in keeps native form:\n{code}" + ); + assert!( + !code.contains("const k"), + "head bindings convert to var:\n{code}" + ); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("bamts-forof-{nonce}.cjs")); + std::fs::write(&path, code).expect("write lowered JavaScript"); + let result = std::process::Command::new("node") + .arg(&path) + .output() + .expect("execute Node"); + let _ = std::fs::remove_file(&path); + assert!( + result.status.success(), + "{}\n{code}", + String::from_utf8_lossy(&result.stderr) + ); + assert!( + String::from_utf8_lossy(&result.stdout).contains("1,2,3,a"), + "the loop must collect every element and key:\n{code}" + ); + } + + #[test] + fn es5_for_of_assignment_target_keeps_rewritten_iterable() { + let output = emit_at( + "async function f() { let x; for (x of await values) {} }\n", + ScriptTarget::Es5, + ); + let code = javascript(&output); + assert!(!code.contains("await values"), "{code}"); + assert!(!code.contains(" of "), "{code}"); + assert!(code.contains("/*yield*/, values]"), "{code}"); + assert!(code.contains("x = _t1[_t0]"), "{code}"); + } + + #[test] + fn computed_destructuring_keys_evaluate_after_earlier_properties() { + let output = emit_at( + "var { a = first(), [second()]: b, c = third() } = source();\n", + ScriptTarget::Es5, + ); + let code = javascript(&output); + let position = |needle: &str| { + code.find(needle) + .unwrap_or_else(|| panic!("{needle}: {code}")) + }; + assert!(position("source()") < position("first()"), "{code}"); + assert!(position("first()") < position("second()"), "{code}"); + assert!(position("second()") < position("third()"), "{code}"); + } + #[test] fn node_executes_lowered_compound_exponentiation_with_single_evaluations() { let output = emit_at( diff --git a/crates/bamts-compiler/src/parser.rs b/crates/bamts-compiler/src/parser.rs index bd34a8fb..0bef9661 100644 --- a/crates/bamts-compiler/src/parser.rs +++ b/crates/bamts-compiler/src/parser.rs @@ -117,6 +117,10 @@ pub const INVALID_USING_DECLARATION: DiagnosticCode = DiagnosticCode::new("BAMTS pub const USING_DECLARATION_REQUIRES_INITIALIZER: DiagnosticCode = DiagnosticCode::new("BAMTS-P012"); const UNTERMINATED_REGEX: DiagnosticCode = DiagnosticCode::new("BAMTS-L004"); +/// An argument-bearing yield expression parsed outside any generator. +const YIELD_OUTSIDE_GENERATOR: DiagnosticCode = DiagnosticCode::new("BAMTS-P017"); +/// An await expression parsed inside a non-async function-like. +const AWAIT_OUTSIDE_ASYNC: DiagnosticCode = DiagnosticCode::new("BAMTS-P018"); /// A parser operation was interrupted before completion. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -280,6 +284,10 @@ struct ParserCheckpoint { struct KeywordContext { await_reserved: bool, yield_reserved: bool, + /// Inside any function-like body or parameter list. Top-level await + /// is legal in modules, so TS1308 fires only when this is set; + /// yield expressions are illegal outside generators everywhere. + in_function: bool, } /// Whether `global` / string-named module forms are recognized as ambient at @@ -2097,7 +2105,15 @@ impl Parser { && property_modifier == PropertyModifier::None && !is_async { - let block = self.parse_block(); + // The block's own keyword context: neither the enclosing + // function's async nor its generator state applies, so both + // await and yield are context-illegal inside. + let context = KeywordContext { + in_function: true, + await_reserved: false, + yield_reserved: false, + }; + let block = self.with_keyword_context(context, Self::parse_block); return self.node(start, ClassMember::StaticBlock(block)); } @@ -2140,10 +2156,16 @@ impl Parser { && self.is_constructor_name(&name) && self.at(TokenKind::LParen) { - let parameters = self.parse_parameter_list(); + let keyword_context = KeywordContext { + in_function: true, + await_reserved: false, + yield_reserved: false, + }; + let parameters = + self.with_keyword_context(keyword_context, |this| this.parse_parameter_list()); let return_type = self.parse_optional_type_annotation(); if self.at(TokenKind::LBrace) { - let body = self.parse_block(); + let body = self.with_keyword_context(keyword_context, Self::parse_block); let _ = return_type; return self.node( start, @@ -2182,6 +2204,7 @@ impl Parser { // Method, getter, or setter. if self.at(TokenKind::LParen) || self.at_less_like() || is_generator { let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: is_generator, }; @@ -3244,6 +3267,7 @@ impl Parser { } fn parse_yield_expression(&mut self, start: Utf16Pos, no_in: bool) -> Expr { + let keyword_range = self.cur().range(); self.bump(); let delegate = self.at(TokenKind::Star) && !self.has_newline_before(); if delegate { @@ -3254,6 +3278,19 @@ impl Parser { } else { None }; + // A yield expression is legal only inside a generator; the + // argument-bearing form is unambiguous, so it reports TS1163 + // wherever it appears outside one. A bare `yield` stays quiet: + // in sloppy code it is a plain identifier reference. + if !self.keyword_context.yield_reserved && argument.is_some() && self.is_typescript() { + // Anchored at the yield keyword; TypeScript sources only, + // since sloppy scripts may call `yield(1)` as an identifier. + self.error_at( + YIELD_OUTSIDE_GENERATOR, + keyword_range, + "A 'yield' expression is only allowed in a generator body.", + ); + } self.node( start, Expression::Yield(YieldExpression { delegate, argument }), @@ -3459,8 +3496,23 @@ impl Parser { } if self.at(TokenKind::KwAwait) && self.can_start_expression_after(1) { + let keyword_range = self.cur().range(); self.bump(); let argument = self.parse_unary_expression(); + // Await belongs to async functions (or module top level, + // which no function-like context covers); a plain nested + // function-like parsing one is TS1308. Anchored at the + // await keyword, TypeScript sources only. + if !self.keyword_context.await_reserved + && self.keyword_context.in_function + && self.is_typescript() + { + self.error_at( + AWAIT_OUTSIDE_ASYNC, + keyword_range, + "'await' expressions are only allowed within async functions and at the top levels of modules.", + ); + } return self.node( start, Expression::Await(AwaitExpression { @@ -4475,6 +4527,7 @@ impl Parser { // Method. if self.at(TokenKind::LParen) || self.at_less_like() { let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: is_generator, }; @@ -4889,6 +4942,7 @@ impl Parser { None }; let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: is_generator, }; @@ -5118,6 +5172,7 @@ impl Parser { fn parse_simple_arrow(&mut self, start: Utf16Pos, is_async: bool, no_in: bool) -> Expr { let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: false, }; @@ -5152,6 +5207,7 @@ impl Parser { fn parse_paren_arrow(&mut self, start: Utf16Pos, is_async: bool, no_in: bool) -> Expr { let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: false, }; @@ -5190,6 +5246,7 @@ impl Parser { ) -> Option { let checkpoint = self.checkpoint(); let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: false, }; @@ -5218,6 +5275,7 @@ impl Parser { let checkpoint = self.checkpoint(); self.bump(); // `async` let keyword_context = KeywordContext { + in_function: true, await_reserved: true, yield_reserved: false, }; @@ -5258,6 +5316,7 @@ impl Parser { return None; } let keyword_context = KeywordContext { + in_function: true, await_reserved: is_async, yield_reserved: false, }; @@ -6555,6 +6614,219 @@ mod tests { .collect() } + /// B3: an argument-bearing yield outside any generator reports the + /// native code that projects onto TS1163; a bare `yield` stays + /// quiet (sloppy identifier reference), and generators stay clean. + #[test] + fn reports_yield_expression_outside_generators() { + let bad = parse_text("function f() { yield 1; }", ScriptKind::TypeScript); + assert!( + errors(&bad) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P017"), + "yield in plain function must report" + ); + let nested = parse_text( + "function* g() { function h() { yield 1; } yield 2; }", + ScriptKind::TypeScript, + ); + assert!( + errors(&nested) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P017"), + "yield in nested plain function must report" + ); + let bare = parse_text("function f() { var x = yield; }", ScriptKind::TypeScript); + assert!( + !errors(&bare) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P017"), + "bare yield is a sloppy identifier, not a report" + ); + assert_clean("function* g() { yield 1; }"); + } + + /// B3: await inside a non-async function-like (nested function or + /// arrow) reports the native code that projects onto TS1308; + /// async bodies and module top level stay clean. + #[test] + fn reports_await_outside_async_function_likes() { + let nested = parse_text( + "async function f() { function g() { return await k; } }", + ScriptKind::TypeScript, + ); + assert!( + errors(&nested) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P018"), + "await in nested plain function must report" + ); + let arrow = parse_text( + "async function f() { var g = () => await k; }", + ScriptKind::TypeScript, + ); + assert!( + errors(&arrow) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P018"), + "await in non-async arrow must report" + ); + assert_clean("async function f() { return await k; }"); + assert_clean("await k;"); + } + + /// B3 follow-ups: the diagnostics anchor at the yield/await keyword + /// (not the token after the operand), sloppy JavaScript stays + /// silent, and class static blocks carry their own context. + #[test] + fn context_diagnostics_anchor_gate_and_static_blocks() { + let bad = parse_text("function f() { yield 1; }", ScriptKind::TypeScript); + let diag = errors(&bad) + .iter() + .find(|d| d.code().as_str() == "BAMTS-P017") + .copied() + .expect("yield diagnostic"); + assert_eq!( + diag.range().start().get(), + "function f() { ".len(), + "range must start at the yield keyword" + ); + let sloppy = parse_text("function f() { yield(1); }", ScriptKind::JavaScript); + assert!( + !errors(&sloppy) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P017"), + "sloppy js identifier call must stay silent" + ); + let sloppy_await = parse_text("function f() { await(1); }", ScriptKind::JavaScript); + assert!( + !errors(&sloppy_await) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P018"), + "sloppy js await call must stay silent" + ); + let static_block = parse_text( + "async function f() { class C { static { await x; } } }", + ScriptKind::TypeScript, + ); + assert!( + errors(&static_block) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P018"), + "await in a static block is context-illegal regardless of the enclosing async" + ); + let static_block_yield = parse_text( + "function* g() { class C { static { yield x; } } }", + ScriptKind::TypeScript, + ); + assert!( + errors(&static_block_yield) + .iter() + .any(|d| d.code().as_str() == "BAMTS-P017"), + "yield in a static block is context-illegal regardless of the enclosing generator" + ); + } + + /// B3: the native codes project onto the TypeScript surface the + /// driver reports - TS1163 and TS1308 with tsc's exact messages. + #[test] + fn projects_context_codes_onto_the_typescript_surface() { + let bad = parse_text("function f() { yield 1; }", ScriptKind::TypeScript); + let projected = crate::diagnostics_parser::typescript_parse_code( + bad.diagnostics()[0].code(), + bad.diagnostics()[0].message(), + ) + .expect("yield code projects"); + assert_eq!(projected.as_str(), "TS1163"); + let awaited = parse_text( + "async function f() { function g() { return await k; } }", + ScriptKind::TypeScript, + ); + let await_code = errors(&awaited) + .iter() + .find(|d| d.code().as_str() == "BAMTS-P018") + .copied() + .expect("await error present"); + let projected = crate::diagnostics_parser::typescript_parse_code( + await_code.code(), + await_code.message(), + ) + .expect("await code projects"); + assert_eq!(projected.as_str(), "TS1308"); + assert_eq!( + crate::diagnostics_parser::typescript_parse_message(projected), + Some( + "'await' expressions are only allowed within async functions and at the top levels of modules." + ) + ); + } + + #[test] + fn pr199_constructor_keyword_context_is_local() { + for (source, expected, keyword) in [ + ( + "/* 🦀 */ class C { constructor() { await value; } }", + "BAMTS-P018", + "await", + ), + ( + "async function f() { class C { constructor() { await value; } } await value; }", + "BAMTS-P018", + "await", + ), + ( + "function* f() { class C { constructor() { yield 1; } } yield 2; }", + "BAMTS-P017", + "yield", + ), + ( + "async function f() { class C { constructor(x = await value) {} } await value; }", + "BAMTS-P018", + "await", + ), + ( + "function* f() { class C { constructor(x = yield 1) {} } yield 2; }", + "BAMTS-P017", + "yield", + ), + ( + "class C { constructor(x = await value); constructor(x) {} }", + "BAMTS-P018", + "await", + ), + ] { + let parsed = parse_text(source, ScriptKind::TypeScript); + let diagnostics = errors(&parsed) + .into_iter() + .filter(|diagnostic| { + matches!(diagnostic.code().as_str(), "BAMTS-P017" | "BAMTS-P018") + }) + .collect::>(); + assert_eq!(diagnostics.len(), 1, "{source}: {diagnostics:?}"); + assert_eq!(diagnostics[0].code().as_str(), expected, "{source}"); + let byte_start = source.find(keyword).expect("keyword in fixture"); + let utf16_start = source[..byte_start].encode_utf16().count(); + assert_eq!( + diagnostics[0].range().start().get(), + utf16_start, + "{source}" + ); + } + for source in [ + "class C { constructor() { const f = async () => await value; const g = function*() { yield 1; }; } }", + "async function f() { class C { constructor() {} } await value; }", + "function* f() { class C { constructor() {} } yield 1; }", + "class C { constructor() {} } await value;", + ] { + let parsed = parse_text(source, ScriptKind::TypeScript); + assert!( + errors(&parsed).is_empty(), + "{source}: {:?}", + errors(&parsed) + ); + } + } + fn assert_clean(text: &str) -> Recovered { let recovered = parse_ts(text); let errs = errors(&recovered); diff --git a/crates/bamts-verification/src/facets.rs b/crates/bamts-verification/src/facets.rs index e13d384c..0d0dc198 100644 --- a/crates/bamts-verification/src/facets.rs +++ b/crates/bamts-verification/src/facets.rs @@ -51,7 +51,7 @@ pub const DIAGNOSTIC_CODE_MAP_PATH: &str = "verification/diagnostic-code-map.jso pub const DIAGNOSTIC_CODE_MAP_SCHEMA_VERSION: u32 = 1; /// Every current BAMTS diagnostic code the map must cover exactly once. -pub const REQUIRED_BAMTS_DIAGNOSTIC_CODES: [&str; 90] = [ +pub const REQUIRED_BAMTS_DIAGNOSTIC_CODES: [&str; 104] = [ "BAMTS-L001", "BAMTS-L002", "BAMTS-L003", @@ -142,6 +142,20 @@ pub const REQUIRED_BAMTS_DIAGNOSTIC_CODES: [&str; 90] = [ "BAMTS-C087", "BAMTS-C088", "BAMTS-C089", + "BAMTS-C090", + "BAMTS-C091", + "BAMTS-C092", + "BAMTS-C093", + "BAMTS-C094", + "BAMTS-C095", + "BAMTS-C096", + "BAMTS-C097", + "BAMTS-C098", + "BAMTS-C099", + "BAMTS-C100", + "BAMTS-C101", + "BAMTS-C102", + "BAMTS-C103", ]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum FacetVerdict { diff --git a/docs/solutions/logic-errors/es5-generator-suspension-walker-divergence.md b/docs/solutions/logic-errors/es5-generator-suspension-walker-divergence.md new file mode 100644 index 00000000..ba9e08b7 --- /dev/null +++ b/docs/solutions/logic-errors/es5-generator-suspension-walker-divergence.md @@ -0,0 +1,148 @@ +--- +title: ES5 generator suspension walkers must agree before inline cloning +date: 2026-09-05 +category: logic-errors +module: compiler +problem_type: logic_error +component: es5-generator-lowerer +severity: critical +symptoms: + - "ES5 lowering of generator functions emits `__generator` helper wrapping a live `yield` inside a plain (non-generator) function" + - "Expressions with array-spread, computed-key update, or pattern-assignment targets are cloned verbatim when the inline-clone gate reports zero yields" + - "debug_assert_eq! on segments/exit_label fires (left:2 right:3) for identifier-assignment loop tests; release builds jump to a wrong case" + - "The contains_yield, count_yields, and contains_await views disagree per shape" +root_cause: logic_error +resolution_type: code_fix +related_components: [emitter, transforms] +tags: [es5, generator, walker, duality, miscompile, inline-clone, sentinel] +applies_when: + - "Editing contains_yield, count_yields, contains_await, contains_yield_array, or statements_contain_await in crates/bamts-compiler/src/emitter/transforms.rs" + - "Adding an expression or member shape the machine may clone inline in loop/branch tests" + - "Changing eval acceptance (eval-refused vs eval-accepted shapes)" +--- + +# ES5 generator suspension walkers must agree before inline cloning + +## Problem + +The ES5 downleveler keeps three overlapping views of "does this expression +suspend?": `contains_yield`/`contains_await` (boolean; gate statement-level +cloning in `machine_emit_expression` and `eval`), `count_yields` (u32; gates +the loop/branch TEST inline clone at `machine_emit_while`/`do`/`for` and +drives `exit_label = head + test_resumes + body_resumes + 1`), and +`contains_yield_array` (boolean; guards `eval_array`). They were not kept in +per-shape sync. Any shape where the inline gate's view under-reports what +`eval` would refuse gets cloned verbatim with a live `yield` inside a plain +function — a silent miscompile. Any shape where it OVER-reports on an +eval-accepted target inflates label arithmetic and mints a wrong case. + +## Symptoms + +Three concrete instances of the one class, all present at HEAD 43bdcb6 and +caught by adversarial review: + +1. `count_yields` had no `Expression::Update` arm. `while (o[yield k]++ < 3)` + counted 0, cloned verbatim, and emitted: + ```js + function g() { return __generator(this, function (_a) { + while (o[yield k]++ < 3) { } + return [2 /*return*/]; + }); } + ``` + (plain `function g`, live `yield` — the never-miscompile contract broken). +2. `contains_yield_array` ignored `ArrayElement::Spread`. `[...(yield x)]` + reported clean and cloned at the statement gate; same wrapper form. +3. A pattern-target sentinel in `count_yields`' Assignment arm swallowed + `AssignmentTarget::Identifier`: `while (x = 1) { await y; }` counted a + phantom resume, inflating `exit_label` — debug builds fail + `debug_assert_eq!(ctx.segments.len() as u32 - 1, exit_label)` with + `left: 2 right: 3`; release builds jump to a wrong case. + +## What Didn't Work + +- Statement-level probes missed instance 1 twice: the statement gate + (`machine_emit_expression`, keyed on `contains_yield` at ~4265) and the + loop-test gate (`machine_emit_while`, keyed on `count_yields` at ~4435) + are DIFFERENT gates. Probe the claimed trigger shape, not an adjacent one. +- Fixing only the boolean walkers left label arithmetic wrong. +- The original `count_yields` catch-all `_ => 0` declared every unlisted + shape "provably clean" — the polarity itself was the defect class. +- Three earlier commits (c3ddb6d, 01e5c5a, 34cef0e) re-learned this class + one arm at a time. + +## Solution + +Two rules, applied to `transforms.rs` (~7016-7130, ~7450-7540): + +1. **Precise arms for eval-ACCEPTED shapes** — count exactly what `eval` + splits: `AssignmentTarget::Identifier(_) => 0`, Member targets + (object + computed key), `Expression::Update` (member object + computed + key; Identifier 0), `Expression::Import` (source + options), and + `ObjectMember::Spread` / `ArrayElement::Spread` arguments. Method-like + object members stay 0 (nested function-likes own their suspensions, + consistent with `Expression::Function => 0`). +2. **Non-zero sentinel ONLY for eval-REFUSED shapes** — the outer catch-all + is `_ => 1` (zero now means provably clean; unknown routes to `eval`, + whose refusal keeps the native generator + `GENERATOR_REQUIRES_ES2015`). + Verified: eval has no accepting arms for TaggedTemplate, Satisfies, + TypeAssertion, or the JSX variants. A sentinel on an eval-accepted shape + is instance 3 again. + +```rust +// count_yields, the two load-bearing arm shapes: +Expression::Update(update) => match update.argument.data() { + AssignmentTarget::Member(member) => { + count_yields(&member.object) + + match &member.property { + MemberProperty::Computed(key) => count_yields(key), + _ => 0, + } + } + AssignmentTarget::Identifier(_) => 0, + _ => 1, // pattern/invalid targets: eval refuses them +}, +// ... + AssignmentTarget::Identifier(_) => 0, + _ => 1, +// outer catch-all: +_ => 1, // unknown: never the inline clone; eval refuses +``` + +## Why This Works + +The inline-clone fast-path (`if test_resumes == 0 && body_resumes == 0 { +ctx.push(statement.clone()); }`) is only safe when zero is a PROOF of +cleanliness. Making every non-provably-clean shape non-zero routes it to +`eval`, which either splits it correctly or refuses (native fallback + +diagnostic) — both contract-compliant. Exact counting on eval-accepted +shapes keeps `exit_label` arithmetic equal to the number of segments eval +actually mints. + +## Prevention + +- Audit `contains_yield` / `count_yields` / `contains_await` / + `statements_contain_await` as ONE SET on any walker edit; the + enumerate-all-arms diff across the three views is the systematic close. +- A sentinel wildcard is only safe on shapes the fallback consumer + (eval) REFUSES; enumerate eval-accepted shapes as explicit arms first. +- Pin the CONTRACT, not just refusal shapes: a pin per gate (statement + gate AND loop-test gate), each mutation-proven by deleting the arm and + recording the exact miscompile output. Existing pins: + `es5_generator_update_computed_key_refuses`, + `es5_generator_array_spread_yield_never_clones`, + `es5_generator_object_spread_yield_refuses`, + `es5_async_identifier_assignment_test_stays_inline`, + `es5_async_for_update_increment_lowers` (emitter.rs cfg(test)). +- The corpus-level regression gate is the suite pair + (`cargo test -p bamts-compiler` + `cargo test -p bamts-verification`), + never the bare CLI `-p` path (it never lowers the machine — refusal form + only). The verification crate's corpus_differential target additionally + demands the pinned `node@24.18.0` first on `PATH` + (`mise install node@24.18.0`, then prefix the install's bin dir) — the + `BAMTS_ALLOW_NODE_COMPAT=1` flag covers the lib target only and leaves + corpus_differential panicking on the version check. That pair is the + leaf evidence; + the completion claim itself binds to the receipt-backed G3 compiler root + gate in `.outline/GATES.md` — a green suite pair is necessary, not + sufficient, and closure language must cite the G3 receipts, not the leaf + suites alone. diff --git a/verification/diagnostic-code-map.json b/verification/diagnostic-code-map.json index 24d0e835..dbd6014f 100644 --- a/verification/diagnostic-code-map.json +++ b/verification/diagnostic-code-map.json @@ -540,6 +540,90 @@ "evidence": "Value or type reference resolves to a known lib-gated global not in the active lib set -> TS2583 in the TypeScript 7.0.2 authority baselines: bigintWithoutLib(target=es5) (line 4).", "status": "mapped", "tsCode": 2583 + }, + { + "bamtsCode": "BAMTS-C090", + "evidence": "Derived constructor reads `this` before a guaranteed super() call -> TS17009 in the TypeScript 7.0.2 authority baselines: checkSuperCallBeforeThisAccess (line 7), checkSuperCallBeforeThisAccessing2 (line 5).", + "status": "mapped", + "tsCode": 17009 + }, + { + "bamtsCode": "BAMTS-C091", + "evidence": "Derived constructor reads a `super` property before a guaranteed super() call -> TS17011 in the TypeScript 7.0.2 authority baselines: checkSuperCallBeforeThisAccess (line 9), checkSuperCallBeforeThisAccessing3 (line 5).", + "status": "mapped", + "tsCode": 17011 + }, + { + "bamtsCode": "BAMTS-C092", + "evidence": "Derived class reads a base class field through `super` -> TS2855 in the TypeScript 7.0.2 authority baselines: checkSuperCallBeforeThisAccess (lines 9, 12, 17, 22, 45).", + "status": "mapped", + "tsCode": 2855 + }, + { + "bamtsCode": "BAMTS-C093", + "evidence": "Derived class reads a base static member through `super` -> TS2576 in the TypeScript 7.0.2 authority baselines: superAccess (line 9, both target variants).", + "status": "mapped", + "tsCode": 2576 + }, + { + "bamtsCode": "BAMTS-C094", + "evidence": "Block-scoped variable referenced textually before its declaration -> TS2448 in the TypeScript 7.0.2 authority baselines: blockScopedEnumVariablesUseBeforeDef_preserve, useBeforeDeclaration variants.", + "status": "mapped", + "tsCode": 2448 + }, + { + "bamtsCode": "BAMTS-C095", + "evidence": "Class referenced textually before its declaration -> TS2449 in the TypeScript 7.0.2 authority baselines: useBeforeDeclaration_classDecorators.1 (lines 7, 10, 29, 30).", + "status": "mapped", + "tsCode": 2449 + }, + { + "bamtsCode": "BAMTS-C096", + "evidence": "Enum referenced textually before its declaration -> TS2450 in the TypeScript 7.0.2 authority baselines: blockScopedEnumVariablesUseBeforeDef (line 2, both target variants).", + "status": "mapped", + "tsCode": 2450 + }, + { + "bamtsCode": "BAMTS-C097", + "evidence": "Rest parameter followed by another parameter -> TS1014 in the TypeScript 7.0.2 authority baselines: restParameterNotLast (line 1).", + "status": "mapped", + "tsCode": 1014 + }, + { + "bamtsCode": "BAMTS-C098", + "evidence": "Interface named after a primitive type -> TS2427 in the TypeScript 7.0.2 authority baselines: InterfaceDeclaration8 (line 1).", + "status": "mapped", + "tsCode": 2427 + }, + { + "bamtsCode": "BAMTS-C099", + "evidence": "Base field read through `super` under an ES5 target -> TS2340 in the TypeScript 7.0.2 authority baselines: superAccess(target=es5) (lines 10, 11).", + "status": "mapped", + "tsCode": 2340 + }, + { + "bamtsCode": "BAMTS-C100", + "evidence": "Parameter initializer on an overload signature -> TS2371 in the TypeScript 7.0.2 authority baselines: defaultValueInConstructorOverload1 (line 2).", + "status": "mapped", + "tsCode": 2371 + }, + { + "bamtsCode": "BAMTS-C101", + "evidence": "Label redeclared within one function -> TS1114 in the TypeScript 7.0.2 authority baselines: duplicateLabel2 (line 3).", + "status": "mapped", + "tsCode": 1114 + }, + { + "bamtsCode": "BAMTS-C102", + "evidence": "Labeled break with no enclosing declaration of the label -> TS1116 in the TypeScript 7.0.2 authority baselines: breakTarget6 (line 2).", + "status": "mapped", + "tsCode": 1116 + }, + { + "bamtsCode": "BAMTS-C103", + "evidence": "Labeled break whose target crosses a function boundary -> TS1107 in the TypeScript 7.0.2 authority baselines: breakTarget5 (line 5).", + "status": "mapped", + "tsCode": 1107 } ] }