diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35e11c6b..5438ed6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,11 +49,34 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets --locked -- -D warnings + # The workspace guard shells out to `cargo metadata --offline`, + # which resolves the graph for every target and so needs crates + # this host build never downloads. `cargo fetch` without `--target` + # gets all of them, matching the Formal job's G0 ledger step. + - name: Fetch dependencies for all targets + run: cargo fetch --locked + - 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 + # `cargo test --workspace` covers authority, oracle-pin, and formal + # bridge tests that read `node_modules/typescript/package.json` and + # drive a local Quint. Without these the step fails on a missing + # toolchain rather than on a real defect. + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + package-manager-cache: false + + - name: Install npm dependencies + run: npm ci + + - name: Install Quint + run: npm ci --prefix formal/quint + - name: Test workspace run: cargo test --workspace --locked @@ -189,6 +212,12 @@ jobs: rustup toolchain install "$TOOLCHAIN" --profile minimal --component rustfmt,clippy,rust-src rustup default "$TOOLCHAIN" + # `ledger verify` shells out to `cargo metadata --offline`, which + # resolves the graph for every target and so needs crates the host + # build never downloads. `cargo fetch` without `--target` gets all. + - name: Fetch dependencies for all targets + run: cargo fetch --locked + - name: G0 ledger run: cargo run --locked -p bamts-verification --bin bamts-verification -- ledger verify --gate G0 @@ -228,6 +257,18 @@ jobs: run: cargo run --locked -p bamts-verification --bin ts_conformance -- sync --verify-pin --write-snapshot - name: Build compiler lane worker run: cargo build --locked --release -p bamts-verification --bin ts_lane_worker + # `suite run` hashes the locked authority markers under + # target/authority. The typescript-7.0.2 catalog reads both the + # compiler tree and the test tree, so both must be materialized + # or the receipt step fails with E_TOOL_MISSING. + - name: Fetch pinned TypeScript compiler authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-7-compiler --dest target/authority/typescript-7.0.2 + - name: Fetch pinned TypeScript test authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-primary-tests --dest target/authority/typescript-7.0.2-tests - name: Write strict conformance receipt env: BAMTS_SUITE_COMPILER_ADAPTER: target/release/ts_lane_worker @@ -285,6 +326,17 @@ jobs: merge-multiple: true - name: Build compiler lane worker run: cargo build --locked --release -p bamts-verification --bin ts_lane_worker + # `suite merge` recomputes the run binding, whose authority digest + # reads both markers under target/authority. The receipts artifact + # carries only JSONL, so this clean runner must materialize them too. + - name: Fetch pinned TypeScript compiler authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-7-compiler --dest target/authority/typescript-7.0.2 + - name: Fetch pinned TypeScript test authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-primary-tests --dest target/authority/typescript-7.0.2-tests - name: Merge complete compatible matrix env: BAMTS_SUITE_COMPILER_ADAPTER: target/release/ts_lane_worker diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6ffa362c..38c7ed90 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -95,6 +95,18 @@ jobs: run: cargo run --locked -p bamts-verification --bin ts_conformance -- sync --verify-pin --write-snapshot - name: Build compiler lane worker run: cargo build --locked --release -p bamts-verification --bin ts_lane_worker + # `suite run` hashes the locked authority markers under + # target/authority. The typescript-7.0.2 catalog reads both the + # compiler tree and the test tree, so both must be materialized + # or the receipt step fails with E_TOOL_MISSING. + - name: Fetch pinned TypeScript compiler authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-7-compiler --dest target/authority/typescript-7.0.2 + - name: Fetch pinned TypeScript test authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-primary-tests --dest target/authority/typescript-7.0.2-tests - name: Write strict nightly receipt env: BAMTS_SUITE_COMPILER_ADAPTER: target/release/ts_lane_worker @@ -148,6 +160,17 @@ jobs: merge-multiple: true - name: Build compiler lane worker run: cargo build --locked --release -p bamts-verification --bin ts_lane_worker + # `suite merge` recomputes the run binding, whose authority digest + # reads both markers under target/authority. The receipts artifact + # carries only JSONL, so this clean runner must materialize them too. + - name: Fetch pinned TypeScript compiler authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-7-compiler --dest target/authority/typescript-7.0.2 + - name: Fetch pinned TypeScript test authority + run: >- + cargo run --locked -p bamts-verification -- source fetch + typescript-primary-tests --dest target/authority/typescript-7.0.2-tests - name: Merge complete compatible matrix env: BAMTS_SUITE_COMPILER_ADAPTER: target/release/ts_lane_worker @@ -359,6 +382,12 @@ jobs: rustup toolchain install "$TOOLCHAIN" --profile minimal --component rustfmt,clippy,rust-src rustup default "$TOOLCHAIN" + # `ledger verify` shells out to `cargo metadata --offline`, which + # resolves the graph for every target and so needs crates the host + # build never downloads. `cargo fetch` without `--target` gets all. + - name: Fetch dependencies for all targets + run: cargo fetch --locked + - name: G0 ledger run: cargo run --locked -p bamts-verification --bin bamts-verification -- ledger verify --gate G0 diff --git a/crates/bamts-cli/src/api_server/mod.rs b/crates/bamts-cli/src/api_server/mod.rs index e3d9b1a5..d061186f 100644 --- a/crates/bamts-cli/src/api_server/mod.rs +++ b/crates/bamts-cli/src/api_server/mod.rs @@ -11,6 +11,7 @@ use std::io::{self, Write}; use std::os::fd::AsFd; use std::sync::Arc; use std::thread; +use std::time::Duration; use control::{Control, ControlKind, Inbound, Next, REAP_DEADLINE, ReaderExit}; use reader::reader_main; @@ -145,6 +146,15 @@ where control.stop(); let _ = waker.wake(); + // `shutdown` ends the loop by design, and the drain owes a terminal + // response to work the transport already carried. Reaping the reader + // first is what makes that set well defined: without it the loop can + // break while the next frame is still being parsed, and that request + // leaves unanswered depending only on thread scheduling. One wait + // serves both the drain and the join below, so a reader that never + // exits costs the deadline once. A reader that cannot be woken keeps + // the existing orphan path rather than stalling shutdown at all. + let reader_reaped = I::Waker::REAPABLE && control.wait_reaped(REAP_DEADLINE); for inbound in control.drain() { match inbound { Inbound::Work { @@ -168,7 +178,14 @@ where } } - let reaped = if I::Waker::REAPABLE && control.wait_reaped(REAP_DEADLINE) { + // A reader that exits while the drain is writing its responses still + // owes its terminal error, so re-read the state once the drain is + // done. A zero deadline reads without waiting again, which keeps the + // single bounded wait above. + let reader_reaped = + reader_reaped || (I::Waker::REAPABLE && control.wait_reaped(Duration::ZERO)); + + let reaped = if reader_reaped { Reaped::Joined( reader .join() diff --git a/crates/bamts-cli/src/driver.rs b/crates/bamts-cli/src/driver.rs index 944cd664..28278b9f 100644 --- a/crates/bamts-cli/src/driver.rs +++ b/crates/bamts-cli/src/driver.rs @@ -2879,27 +2879,30 @@ printf started > link-started let cancel = CancellationToken::new(); let trigger = cancel.clone(); let marker = directory.join("link-started"); + // Wait for the linker to actually start rather than for a fixed + // slice of wall clock: a loaded runner can take longer to spawn + // the process than a short deadline allows. The cap only breaks a + // genuine hang, and the returned instant dates the cancel so the + // bound below measures cancellation, not process startup. let canceller = thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(2); + let deadline = Instant::now() + Duration::from_secs(60); while !marker.is_file() && Instant::now() < deadline { thread::sleep(Duration::from_millis(5)); } let started = marker.is_file(); trigger.cancel(); - started + started.then(Instant::now) }); - let started = Instant::now(); let error = link_executable(&[], &directory.join("output"), &context, &cancel) .expect_err("cancelled linker must fail as cancellation"); assert!(matches!(error, DriverError::Cancelled)); + let cancelled_at = canceller + .join() + .expect("link cancellation thread completes") + .expect("the linker must start before it is cancelled"); assert!( - canceller - .join() - .expect("link cancellation thread completes") - ); - assert!( - started.elapsed() < Duration::from_secs(3), + cancelled_at.elapsed() < Duration::from_secs(3), "managed link cancellation must be bounded" ); fs::remove_dir_all(directory)?; diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 12a7cf2b..532de8a5 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -5495,6 +5495,75 @@ pub(crate) fn is_numeric_enum_initializer(expression: &Expr) -> bool { _ => false, } } +/// Returns whether an enum initializer is syntactically a string value, +/// matching the enum plan's reverse-mapping rule: string literals, template +/// literals, transparent type wrappers around strings, and binary `+` where +/// EITHER operand is a string. +fn is_syntactically_string_initializer(expression: &Expr) -> bool { + match expression.data() { + Expression::Literal(Literal::String(_)) => true, + Expression::Template(_) => true, + // Transparent wrappers: unwrap recursively + Expression::Parenthesized(inner) => is_syntactically_string_initializer(inner), + Expression::As(as_expr) => is_syntactically_string_initializer(&as_expr.expression), + Expression::Satisfies(satisfies_expr) => { + is_syntactically_string_initializer(&satisfies_expr.expression) + } + Expression::TypeAssertion(assertion) => { + is_syntactically_string_initializer(&assertion.expression) + } + Expression::NonNull(non_null) => is_syntactically_string_initializer(&non_null.expression), + // Binary `+`: string if EITHER side is string (|| not &&) + Expression::Binary(binary) if binary.operator == BinaryOperator::Add => { + is_syntactically_string_initializer(&binary.left) + || is_syntactically_string_initializer(&binary.right) + } + _ => false, + } +} + +/// Builds the `typeof E` constructor from value-member types without a +/// binder handle: `finish` calls this after `self.types` has moved into +/// the semantic model. Numeric enums additionally carry the +/// reverse-mapping index signature (`E[0]: string`); string enums carry +/// members only. +fn constructor_with_members_in( + types: &mut TypeTable, + symbol: SymbolId, + member_types: Vec<(String, TypeId)>, + numeric_index: bool, +) -> TypeId { + let properties = member_types + .into_iter() + .map(|(name, type_id)| PropertyType::new(name, false, type_id)) + .collect(); + let mut object = ObjectType { + properties, + call_signatures: Vec::new(), + call_candidate_order: Vec::new(), + construct_signatures: Vec::new(), + index_signatures: Vec::new(), + generator_return: None, + iterator_property: None, + async_iterator_property: None, + }; + if numeric_index { + object.index_signatures.push(IndexSignature { + readonly: false, + parameters: vec![FunctionParameter::new( + "index".to_owned(), + types.number(), + false, + false, + )], + value_type: types.string(), + declaring_types: Vec::new(), + }); + } + let structural = types.object_type_with_members(object); + types.constructor_type(symbol, Vec::new(), structural) +} + /// Duplicate declarations of one member name share the canonical `symbol` /// while each keeps its own `declaration` and `name_range`, so consumers can /// render every written occurrence without minting extra symbols. @@ -6065,6 +6134,14 @@ pub(crate) struct Binder<'src> { /// identity, so class-owned statics carry no distinguishing mark /// from our additions without this set. ns_appended_statics: HashSet<(SymbolId, String)>, + /// Properties a base-namespace propagation refreshed on a + /// descendant, distinct from that descendant's own namespace + /// appends: propagation must leave own statics and own appends + /// alone while still refreshing inherited snapshots. The value + /// records the originating ancestor and the inheritance depth from + /// the leaf owner, so a nearer ancestor's value is not replaced by + /// a farther one. + ns_propagated_statics: HashMap<(SymbolId, String), (SymbolId, u32)>, import_equals_symbols: HashMap, qualified_import_paths: HashMap>, import_equals_targets: HashMap, @@ -6093,6 +6170,17 @@ pub(crate) struct Binder<'src> { /// all merged declarations. Scalar classification stays all-members, /// but the reverse-mapping index needs only one numeric member. enum_has_numeric_member: HashSet, + /// String-valued member names per enum symbol, accumulated across + /// merged declarations. A later declaration resolves a reference such + /// as `enum E { A = "a" } enum E { B = A }` against the members an + /// earlier declaration already classified. + enum_string_valued_members: HashMap>, + /// Value-member types per enum symbol in `bind_enum` order, so + /// `finish` can rebuild the constructor from the enum plan after + /// `self.types` has moved into the semantic model. Merged rebuilds + /// overwrite the entry, matching how `enum_constructor_types` is + /// overwritten today. + enum_constructor_members: HashMap>, reg_exp_instance_type: Option, /// Shared by provisional and final class-shape passes so a generic method's /// type parameters keep one semantic identity. @@ -6290,17 +6378,20 @@ impl<'src> Binder<'src> { member_reference_recorded: HashSet::new(), property_sites: Vec::new(), property_site_index: HashMap::new(), - ns_appended_statics: HashSet::new(), property_anchors: Vec::new(), property_anchor_index: HashMap::new(), literal_anchor: HashMap::new(), symbol_anchor: HashMap::new(), reported_static_collisions: HashSet::new(), + ns_appended_statics: HashSet::new(), + ns_propagated_statics: HashMap::new(), import_equals_symbols: HashMap::new(), qualified_import_paths: HashMap::new(), import_equals_targets: HashMap::new(), enum_constructor_types: HashMap::new(), + enum_constructor_members: HashMap::new(), enum_has_numeric_member: HashSet::new(), + enum_string_valued_members: HashMap::new(), class_constructor_types: HashMap::new(), imported_type_parameters: HashMap::new(), imported_type_planes: HashMap::new(), @@ -9444,6 +9535,7 @@ impl<'src> Binder<'src> { let enum_declarations = std::mem::take(&mut self.enum_declarations); let enum_member_symbols = std::mem::take(&mut self.enum_member_symbols); let enum_member_names = std::mem::take(&mut self.enum_member_names); + let enum_constructor_members = std::mem::take(&mut self.enum_constructor_members); let enum_member_identifier_uses = std::mem::take(&mut self.enum_member_identifier_uses); let imported_enum_member_uses = std::mem::take(&mut self.imported_enum_member_uses); let local_enum_member_targets = std::mem::take(&mut self.local_enum_member_targets); @@ -9529,6 +9621,33 @@ impl<'src> Binder<'src> { &imported_enum_member_targets, ) }; + // The enum plan is the authority on the reverse-mapping index. + // Reconcile the provisional constructor: rebuild only where the + // plan disagrees with the binder's guess, so untouched enums keep + // their exact TypeId. + let mut plan_reverse_by_symbol: HashMap = HashMap::new(); + for binding in &enum_declarations { + let Some(plan) = enum_facts.declaration(binding.declaration_id) else { + continue; + }; + let reverse = plan.members().iter().any(|member| member.reverse()); + *plan_reverse_by_symbol.entry(binding.symbol).or_default() |= reverse; + } + for (symbol, reverse_mapped) in plan_reverse_by_symbol { + if reverse_mapped == self.enum_has_numeric_member.contains(&symbol) { + continue; + } + let Some(member_types) = enum_constructor_members.get(&symbol) else { + continue; + }; + let constructor = constructor_with_members_in( + &mut model.types, + symbol, + member_types.clone(), + reverse_mapped, + ); + model.enum_constructor_types.insert(symbol, constructor); + } model.enum_facts = enum_facts; let mut namespace_facts = namespace_plan::build( &model, @@ -10933,35 +11052,7 @@ impl<'src> Binder<'src> { member_types: Vec<(String, TypeId)>, numeric_index: bool, ) -> TypeId { - let properties = member_types - .into_iter() - .map(|(name, type_id)| PropertyType::new(name, false, type_id)) - .collect(); - let mut object = ObjectType { - properties, - call_signatures: Vec::new(), - call_candidate_order: Vec::new(), - construct_signatures: Vec::new(), - index_signatures: Vec::new(), - generator_return: None, - iterator_property: None, - async_iterator_property: None, - }; - if numeric_index { - object.index_signatures.push(IndexSignature { - readonly: false, - parameters: vec![FunctionParameter::new( - "index".to_owned(), - self.types.number(), - false, - false, - )], - value_type: self.types.string(), - declaring_types: Vec::new(), - }); - } - let structural = self.types.object_type_with_members(object); - self.types.constructor_type(symbol, Vec::new(), structural) + constructor_with_members_in(&mut self.types, symbol, member_types, numeric_index) } fn bind_enum( @@ -11021,15 +11112,41 @@ impl<'src> Binder<'src> { .as_deref() .is_none_or(is_numeric_enum_initializer) }); - // Reverse mappings need only one numeric member: heterogeneous - // enums still emit `E[1]` entries for their numeric half. - if declaration.members.iter().any(|member| { - member - .data() - .initializer - .as_deref() - .is_none_or(is_numeric_enum_initializer) - }) { + // Reverse mapping follows the enum plan's rule: a member earns it + // unless its initializer is string-valued. Syntax settles literals, + // templates, transparent wrappers, and concatenation; a bare + // reference to an earlier member of this enum is resolved against + // the members already walked above. Anything else, a call or + // arithmetic, stays numeric, so a computed member keeps its runtime + // reverse mapping. This has to be right here rather than in + // `finish`, because member accesses are typed before the plan runs + // and a late correction cannot retract a diagnostic. + // The enum's own entry is taken out while the walk runs, so a + // self-reference reads the in-progress set rather than a stale one. + let mut string_valued = self + .enum_string_valued_members + .remove(&symbol) + .unwrap_or_default(); + let mut has_numeric_member = false; + for member in &declaration.members { + let Some(name) = enum_plan::cook_member_name(self.source, &member.data().name) else { + continue; + }; + let Some(initializer) = member.data().initializer.as_deref() else { + has_numeric_member = true; + continue; + }; + if is_syntactically_string_initializer(initializer) + || self.references_string_enum_member(initializer, scope, symbol, &string_valued) + { + string_valued.insert(name.to_utf8_lossy()); + } else { + has_numeric_member = true; + } + } + self.enum_string_valued_members + .insert(symbol, string_valued); + if has_numeric_member { self.enum_has_numeric_member.insert(symbol); } match self.type_defs.get_mut(&symbol) { @@ -11068,6 +11185,8 @@ impl<'src> Binder<'src> { } } let reverse_mapped = self.enum_has_numeric_member.contains(&symbol); + self.enum_constructor_members + .insert(symbol, member_types.clone()); let constructor = self.constructor_with_members(symbol, member_types, reverse_mapped); self.enum_constructor_types.insert(symbol, constructor); self.enum_declaration_symbols.insert(declaration_id, symbol); @@ -12629,31 +12748,71 @@ impl<'src> Binder<'src> { if additions.is_empty() { return; } - self.merge_ns_additions_into_static(symbol, &additions); + self.merge_ns_additions_into_static(symbol, &additions, true, symbol, 0); // Derived constructors resolved before this augmentation // snapshotted the base statics: refresh them with the same // additions so late-merged members stay visible through // subclasses. Worklist covers transitive descendants. - let mut stack: Vec = self + let mut stack: Vec<(SymbolId, u32)> = self .class_base_symbols .iter() - .filter_map(|(derived, base)| (*base == symbol).then_some(*derived)) + .filter_map(|(derived, base)| (*base == symbol).then_some((*derived, 1))) .collect(); // Cyclic heritage (`A extends B`, `B extends A`) must terminate: // mirror `is_derived_from`'s visited guard. let mut visited = HashSet::new(); - while let Some(derived) = stack.pop() { + while let Some((derived, depth)) = stack.pop() { if !visited.insert(derived) { continue; } - self.merge_ns_additions_into_static(derived, &additions); + self.merge_ns_additions_into_static(derived, &additions, false, symbol, depth); stack.extend( self.class_base_symbols .iter() - .filter_map(|(child, base)| (*base == derived).then_some(*child)), + .filter_map(|(child, base)| (*base == derived).then_some((*child, depth + 1))), ); } } + + /// Inheritance distance from `owner` up to `ancestor`, with `owner` + /// itself at zero, or `None` when `ancestor` is off that chain. The + /// visited set keeps a cyclic `extends` graph from looping. + fn inheritance_depth(&self, owner: SymbolId, ancestor: SymbolId) -> Option { + let mut current = owner; + let mut visited = HashSet::new(); + for depth in 0.. { + if current == ancestor { + return Some(depth); + } + if !visited.insert(current) { + return None; + } + current = *self.class_base_symbols.get(¤t)?; + } + None + } + + /// Distance from `owner` to the nearest strict ancestor whose own + /// namespace appended `name`. A value inherited from a namespace + /// carries no declaring class, so the chain is the only record of + /// where it came from. `None` means no ancestor appended it. + fn nearest_ns_append_depth(&self, owner: SymbolId, name: &str) -> Option { + let mut key = (owner, name.to_owned()); + let mut visited = HashSet::new(); + let mut current = owner; + for depth in 0.. { + key.0 = current; + if depth > 0 && self.ns_appended_statics.contains(&key) { + return Some(depth); + } + if !visited.insert(current) { + return None; + } + current = *self.class_base_symbols.get(¤t)?; + } + None + } + /// Fold namespace exports into one class static shape. An own static /// colliding with a value export is a duplicate declaration (tsc /// TS2300, approximated by C001 pending a dedicated code); inherited @@ -12661,11 +12820,17 @@ impl<'src> Binder<'src> { /// derived merge validly narrows a base static, and fragments must /// not collide with themselves). The append set tells our own /// additions apart from class-owned statics, which share no - /// distinguishing mark on merged symbols. + /// distinguishing mark on merged symbols. Propagated calls + /// (`direct == false`) refresh inherited snapshots only: descendant + /// overrides and own-namespace appends win silently, tracked by + /// the propagated set so later base exports still refresh them. fn merge_ns_additions_into_static( &mut self, owner: SymbolId, additions: &[(String, TypeId, SymbolId)], + direct: bool, + source: SymbolId, + depth: u32, ) { let Some(&existing) = self.class_constructor_types.get(&owner) else { return; @@ -12692,34 +12857,92 @@ impl<'src> Binder<'src> { object .properties .push(PropertyType::new(name.clone(), false, *type_id)); - self.ns_appended_statics.insert((owner, name.clone())); + if direct { + self.ns_appended_statics.insert((owner, name.clone())); + } else { + self.ns_propagated_statics + .insert((owner, name.clone()), (source, depth)); + } changed = true; } Some(index) => { let ours = self.ns_appended_statics.contains(&(owner, name.clone())); let own_static = !ours && object.properties[index].declaring_class() == Some(owner); - if own_static { - if self - .reported_static_collisions - .insert((owner, name.clone())) - { - let range = self.symbols[member.get() as usize].range; - self.emit(DUPLICATE_DECLARATION, range, DUPLICATE_MESSAGE); + if direct { + if own_static { + if self + .reported_static_collisions + .insert((owner, name.clone())) + { + let range = self.symbols[member.get() as usize].range; + self.emit(DUPLICATE_DECLARATION, range, DUPLICATE_MESSAGE); + } + } else { + object.properties[index] = + PropertyType::new(name.clone(), false, *type_id); + self.ns_appended_statics.insert((owner, name.clone())); + changed = true; + } + } else if !(ours || own_static) { + // Propagation refreshes inherited snapshots only: + // a descendant-owned static legally shadows the + // base export, and the descendant's own namespace + // appends win over later base exports. A nearer + // ancestor's value takes precedence over a farther + // one; the same ancestor still refreshes. An + // inherited own static carries no propagation + // record, so rank it by where it was declared, + // and one inherited from a namespace by the + // nearest ancestor that appended it. + let key = (owner, name.clone()); + let refreshes = match self.ns_propagated_statics.get(&key) { + Some(&(_, recorded_depth)) => depth <= recorded_depth, + None => { + let incumbent = object.properties[index] + .declaring_class() + .and_then(|declaring| self.inheritance_depth(owner, declaring)) + .or_else(|| self.nearest_ns_append_depth(owner, name)); + incumbent.is_none_or(|inherited| depth <= inherited) + } + }; + if refreshes { + object.properties[index] = + PropertyType::new(name.clone(), false, *type_id); + self.ns_propagated_statics.insert(key, (source, depth)); + changed = true; } - } else { - object.properties[index] = PropertyType::new(name.clone(), false, *type_id); - self.ns_appended_statics.insert((owner, name.clone())); - changed = true; } } } } - if changed { - let structural = self.types.object_type_with_members(object); - let constructor = self.types.constructor_type(owner, arguments, structural); - self.class_constructor_types.insert(owner, constructor); + if !changed { + return; } + let structural = self.types.object_type_with_members(object); + let constructor = self.types.constructor_type(owner, arguments, structural); + self.class_constructor_types.insert(owner, constructor); + self.refresh_captured_constructor_views(existing, constructor); + } + + /// Refresh views captured before a namespace augmentation. + /// `const alias = D` stores the pre-merge constructor id, so a later + /// export would stay invisible through the alias while `D` sees it. + /// Only top-level exact id matches move forward; reassigned variables + /// hold a different id and stay untouched. The baseline record advances + /// with the semantic slots so `.types` renders the same constructor. + /// Captures nested inside interned types need a representation fix + /// tracked separately. + fn refresh_captured_constructor_views(&mut self, existing: TypeId, current: TypeId) { + self.symbol_types + .iter_mut() + .chain(self.node_types.values_mut()) + .filter(|ty| **ty == existing) + .for_each(|ty| *ty = current); + self.typed_expressions + .iter_mut() + .filter(|entry| entry.1 == existing) + .for_each(|entry| entry.1 = current); } fn finalize_namespace_constructor(&mut self, statement_id: NodeId) { @@ -12773,6 +12996,13 @@ impl<'src> Binder<'src> { // Merged rebuilds keep whatever reverse mapping any declaration // earned; pure namespaces never set the flag, so members-only. let numeric_index = self.enum_has_numeric_member.contains(&symbol); + if merged_enum { + // Merged rebuilds overwrite the recorded member list too, so + // a later plan reconciliation in `finish` sees the same + // members as `enum_constructor_types`. + self.enum_constructor_members + .insert(symbol, member_types.clone()); + } let constructor = self.constructor_with_members(symbol, member_types, numeric_index); if merged_enum { self.enum_constructor_types.insert(symbol, constructor); @@ -23367,6 +23597,109 @@ impl<'src> Binder<'src> { _ => None, } } + + /// Resolves the expression naming an enum in a member access to its + /// symbol: a bare `F`, or a qualified `N.F` at any nesting, walking + /// each container's member scope. Anything else, including a value + /// this file cannot see, resolves to `None` and leaves the member + /// numeric. + fn enum_owner_symbol(&self, expression: &Expr, scope: ScopeId) -> Option { + match expression.data() { + Expression::Identifier(identifier) => { + self.lookup_value(scope, self.identifier_text(identifier).as_ref()) + } + Expression::Member(member) => { + let container = self.enum_owner_symbol(&member.object, scope)?; + let member_scope = self.container_member_scope(container)?; + // An intermediate segment reads the same way as the final + // one, so `N.F` and `N["F"]` both resolve. + let name = enum_plan::cook_member_property_name(self.source, &member.property)?; + self.scopes[member_scope.0 as usize].value(name.to_utf8_lossy().as_str()) + } + _ => None, + } + } + + /// Whether an enum member initializer resolves to a string-valued + /// enum member: a bare name in `enum E { A = "a", B = A }`, a + /// qualified `E.A` or `E["A"]`, a member of an enum bound earlier as + /// in `enum F { A = "a" } enum E { B = F.A }` or reached through a + /// namespace as `N.F.A`, or a concatenation of + /// any of those. Such a member is string-valued, so it earns no + /// reverse mapping. A reference this cannot settle stays numeric, + /// which keeps the index signature present and never reports a + /// spurious missing member. + fn references_string_enum_member( + &self, + expression: &Expr, + scope: ScopeId, + owner: SymbolId, + string_valued: &HashSet, + ) -> bool { + match expression.data() { + Expression::Identifier(identifier) => { + string_valued.contains(self.identifier_text(identifier).as_ref()) + } + // `E.A`, `F.A`, and `N.F.A` are the same shape: resolve the + // object to the enum that owns the member, then ask that + // enum's members. The enum being bound answers from the + // in-progress set, since its entry lands only once the walk + // finishes. + Expression::Member(member) => { + let Some(target) = self.enum_owner_symbol(&member.object, scope) else { + return false; + }; + let members = if target == owner { + Some(string_valued) + } else { + self.enum_string_valued_members.get(&target) + }; + members.is_some_and(|members| { + enum_plan::cook_member_property_name(self.source, &member.property) + .is_some_and(|name| members.contains(name.to_utf8_lossy().as_str())) + }) + } + // `+` yields a string when either side does, matching the + // syntactic classifier's rule for literal operands. + Expression::Binary(binary) if binary.operator == BinaryOperator::Add => { + self.references_string_enum_member(&binary.left, scope, owner, string_valued) + || self.references_string_enum_member( + &binary.right, + scope, + owner, + string_valued, + ) + } + Expression::Parenthesized(inner) => { + self.references_string_enum_member(inner, scope, owner, string_valued) + } + Expression::As(expression) => self.references_string_enum_member( + &expression.expression, + scope, + owner, + string_valued, + ), + Expression::Satisfies(expression) => self.references_string_enum_member( + &expression.expression, + scope, + owner, + string_valued, + ), + Expression::TypeAssertion(expression) => self.references_string_enum_member( + &expression.expression, + scope, + owner, + string_valued, + ), + Expression::NonNull(expression) => self.references_string_enum_member( + &expression.expression, + scope, + owner, + string_valued, + ), + _ => false, + } + } } /// A default zero range for synthesized diagnostics anchored on missing syntax. diff --git a/crates/bamts-compiler/src/enum_plan.rs b/crates/bamts-compiler/src/enum_plan.rs index 6f1225ac..84e4fbf2 100644 --- a/crates/bamts-compiler/src/enum_plan.rs +++ b/crates/bamts-compiler/src/enum_plan.rs @@ -1209,8 +1209,8 @@ pub(crate) fn cook_member_property_name( ) -> Option { match property { MemberProperty::Named(identifier) => source - .token_text(identifier.data().token()) - .map(EcmaString::encode), + .identifier_text(identifier.data().token()) + .map(|name| EcmaString::encode(name.as_ref())), MemberProperty::Computed(expression) => match expression.data() { Expression::Literal(Literal::String(string)) => source .token_text(string.data().token()) diff --git a/crates/bamts-compiler/tests/enum_reverse_mapping.rs b/crates/bamts-compiler/tests/enum_reverse_mapping.rs new file mode 100644 index 00000000..43b0b1f6 --- /dev/null +++ b/crates/bamts-compiler/tests/enum_reverse_mapping.rs @@ -0,0 +1,195 @@ +//! A numeric enum carries a reverse-mapping index signature at runtime and a +//! string enum does not, so `E[0]` is valid for one and not the other. The +//! binder must settle that classification before member accesses are typed: +//! the enum plan reconciles later, and a late correction cannot retract an +//! accepted access. These cases guard the classification, not its plumbing. + +use std::sync::Arc; + +use bamts_compiler::{ + checker::check, + parser, scanner, + source::{ScriptKind, SourceId, SourceText, TextRange}, +}; + +/// Checker diagnostics for one TypeScript source, lint codes excluded. +fn checker_diagnostics(source: &str) -> Vec<(String, TextRange)> { + let source_text = + Arc::new(SourceText::new(source).expect("test source fits the per-file budget")); + let scanned = scanner::scan(SourceId::new(0), ScriptKind::TypeScript, source_text); + let checked = check(&parser::parse(scanned)); + checked + .diagnostics() + .iter() + .filter(|diagnostic| diagnostic.code().as_str().starts_with("BAMTS-C")) + .map(|diagnostic| (diagnostic.code().as_str().to_owned(), diagnostic.range())) + .collect() +} + +fn source_range(source: &str, start: usize, end: usize) -> TextRange { + let source_text = SourceText::new(source).expect("test source fits the per-file budget"); + source_text + .range( + source_text + .byte_to_utf16(start) + .expect("diagnostic start is a source boundary"), + source_text + .byte_to_utf16(end) + .expect("diagnostic end is a source boundary"), + ) + .expect("diagnostic range endpoints are ordered") +} + +/// C057 for `E[0]` is anchored on the computed key expression, not on an +/// unrelated diagnostic from an escaped member access in the initializer. +fn e_index_key_range(source: &str) -> TextRange { + let member_start = source + .find("E[0]") + .expect("the regression source contains the E[0] lookup"); + source_range( + source, + member_start + "E[".len(), + member_start + "E[0".len(), + ) +} + +fn has_code_at(diagnostics: &[(String, TextRange)], code: &str, range: TextRange) -> bool { + diagnostics + .iter() + .any(|(actual_code, actual_range)| actual_code == code && *actual_range == range) +} + +/// Checker diagnostic codes for one TypeScript source, lint codes excluded. +fn checker_codes(source: &str) -> Vec { + checker_diagnostics(source) + .into_iter() + .map(|(code, _)| code) + .collect() +} + +#[test] +fn escaped_intermediate_string_reference_reports_c057_at_e_index() { + let source = r#"namespace N { export enum F { A = "a" } } +enum E { B = N.\u0046.A } +const v = E[0]; +"#; + let diagnostics = checker_diagnostics(source); + assert_eq!( + diagnostics, + [("BAMTS-C057".to_owned(), e_index_key_range(source))] + ); +} + +#[test] +fn escaped_final_string_reference_reports_c057_at_e_index() { + let source = r#"enum E { A = "a", B = E.\u0041 } +const v = E[0]; +"#; + let diagnostics = checker_diagnostics(source); + assert_eq!( + diagnostics, + [("BAMTS-C057".to_owned(), e_index_key_range(source))] + ); +} + +#[test] +fn escaped_member_references_are_clean_without_index_lookup() { + for source in [ + r#"namespace N { export enum F { A = "a" } } +enum E { B = N.\u0046.A } +"#, + r#"enum E { A = "a", B = E.\u0041 } +"#, + ] { + let diagnostics = checker_diagnostics(source); + assert!( + diagnostics.is_empty(), + "escaped member reference should resolve cleanly: {diagnostics:?}" + ); + } +} + +#[test] +fn escaped_numeric_member_references_do_not_report_c057_at_e_index() { + for source in [ + r#"namespace N { export enum F { A = 1 } } +enum E { B = N.\u0046.A } +const v = E[0]; +"#, + r#"enum E { A = 1, B = E.\u0041 } +const v = E[0]; +"#, + ] { + let diagnostics = checker_diagnostics(source); + assert!( + diagnostics.is_empty(), + "numeric escaped member reference should resolve cleanly: {diagnostics:?}" + ); + } +} + +#[test] +fn string_members_reject_a_numeric_lookup() { + for source in [ + // A bare literal, and the wrappers that leave it a string. + "enum E { A = \"a\" }\nconst v = E[0];\n", + "enum E { A = `a` }\nconst v = E[0];\n", + "enum E { A = (\"a\") }\nconst v = E[0];\n", + "enum E { A = \"a\" as string }\nconst v = E[0];\n", + // A reference to an earlier member, bare and qualified. + "enum E { A = \"a\", B = A }\nconst v = E[0];\n", + "enum E { A = \"a\", B = E.A }\nconst v = E[0];\n", + "enum E { A = \"a\", B = E[\"A\"] }\nconst v = E[0];\n", + // A member of another enum, which resolves by symbol, whether it + // is named directly or through a namespace. + "enum F { A = \"a\" }\nenum E { B = F.A }\nconst v = E[0];\n", + "namespace N { export enum F { A = \"a\" } }\nenum E { B = N.F.A }\nconst v = E[0];\n", + "namespace N { export enum F { A = \"a\" } }\nenum E { B = N[\"F\"].A }\nconst v = E[0];\n", + "enum E { A = \"a\" }\nenum E { B = A }\nconst v = E[0];\n", + // Concatenation is a string when either side is. + "enum E { A = \"a\", B = A + A }\nconst v = E[0];\n", + "enum E { A = \"a\", B = A + 1 }\nconst v = E[0];\n", + ] { + let diagnostics = checker_diagnostics(source); + assert!( + has_code_at(&diagnostics, "BAMTS-C057", e_index_key_range(source)), + "a string enum has no reverse mapping: {source}\n{diagnostics:?}" + ); + } +} + +#[test] +fn numeric_members_keep_their_reverse_mapping() { + for source in [ + // Auto-numbered, literal, and computed members are all numeric. + "enum E { A }\nconst v = E[0];\n", + "enum E { A = 1 }\nconst v = E[0];\n", + "enum E { A = Math.random() }\nconst v = E[0];\n", + "enum E { A = 1 << 2 }\nconst v = E[0];\n", + // A reference resolving to a numeric member stays numeric, whether + // it names this enum or another one. + "enum E { A = 1, B = A + A }\nconst v = E[0];\n", + "enum F { A = 1 }\nenum E { B = F.A }\nconst v = E[0];\n", + "namespace N { export enum F { A = 1 } }\nenum E { B = N.F.A }\nconst v = E[0];\n", + "namespace N { export enum F { A = 1 } }\nenum E { B = N[\"F\"].A }\nconst v = E[0];\n", + "enum E { A = \"a\", B = 1 }\nconst v = E[0];\n", + ] { + assert!( + checker_codes(source).is_empty(), + "a numeric enum keeps its reverse mapping: {source}" + ); + } +} + +#[test] +fn an_unresolvable_reference_stays_numeric() { + // The classification has to be right before the plan runs, so a + // reference the binder cannot settle keeps the index signature rather + // than reporting a member that may well exist. + assert!( + checker_codes("enum E { A = Unknown.member }\nconst v = E[0];\n") + .iter() + .all(|code| code != "BAMTS-C057"), + "an unsettled reference must not claim the enum is string-valued" + ); +} diff --git a/crates/bamts-compiler/tests/namespace_static_inheritance.rs b/crates/bamts-compiler/tests/namespace_static_inheritance.rs new file mode 100644 index 00000000..61936708 --- /dev/null +++ b/crates/bamts-compiler/tests/namespace_static_inheritance.rs @@ -0,0 +1,180 @@ +//! A namespace merged into a class contributes statics, and those statics +//! reach every descendant. When more than one ancestor contributes the same +//! name, the nearest one wins, and a descendant's own static or own namespace +//! append outranks any of them. These cases guard that precedence, which is +//! observable through the type a member access reads. + +use std::sync::Arc; + +use bamts_compiler::{ + checker::check, + parser, scanner, + source::{ScriptKind, SourceId, SourceText}, +}; + +/// Checker diagnostics for one TypeScript source, lint codes excluded. +fn checker_codes(source: &str) -> Vec { + let scanned = scanner::scan( + SourceId::new(0), + ScriptKind::TypeScript, + Arc::new(SourceText::new(source).expect("test source fits the per-file budget")), + ); + let checked = check(&parser::parse(scanned)); + checked + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code().as_str().to_owned()) + .filter(|code| code.starts_with("BAMTS-C")) + .collect() +} + +/// Asserts the source checks clean. A literal-typed annotation is the probe: +/// it fails only when the access reads a wider or different type. +fn assert_reads_clean(source: &str, what: &str) { + let codes = checker_codes(source); + assert!(codes.is_empty(), "{what}: {codes:?}"); +} + +#[test] +fn a_descendants_own_static_shadows_a_base_namespace_export() { + assert_reads_clean( + "class C {}\nnamespace C { export const x: number = 1; }\n\ + class D extends C { static x: 2 = 2; }\nconst n: 2 = D.x;\n", + "an own static keeps its own type", + ); +} + +#[test] +fn a_nearer_ancestor_outranks_a_farther_one() { + // B overrides C's static, so a later export from C must not reach past B + // into D. + assert_reads_clean( + "class C { static x: 1 = 1; }\nnamespace C { export const y = 1; }\n\ + class B extends C { static x: 2 = 2; }\nclass D extends B {}\n\ + namespace C { export const z = 1; }\nconst n: 2 = D.x;\n", + "a nearer own static outranks a farther export", + ); +} + +#[test] +fn a_nearer_namespace_export_outranks_a_farther_one() { + // The nearer value arrives through a namespace, so it carries no + // declaring class and the inheritance chain is its only provenance. + assert_reads_clean( + "class C {}\nclass B extends C {}\nnamespace B { export const x = 2; }\n\ + class D extends B {}\n\ + namespace C { export const x: number = 1; }\nconst n: 2 = D.x;\n", + "a nearer namespace export outranks a farther one", + ); +} + +#[test] +fn a_descendant_declared_late_keeps_its_nearer_origin() { + // D is prepared in a nested statement list after B's augmentation has + // finished, which is the case that has no propagation record at all. + assert_reads_clean( + "class C {}\nclass B extends C {}\nnamespace B { export const x = 2; }\n\ + namespace N { export class D extends B {} }\n\ + namespace C { export const x: number = 1; }\n\ + namespace N { export const n: 2 = D.x; }\n", + "a late descendant keeps its nearer origin", + ); +} + +#[test] +fn a_base_export_still_refreshes_an_inherited_snapshot() { + // Precedence must not freeze the chain: an export from the same ancestor + // that supplied the value still reaches the descendant. + assert_reads_clean( + "class C {}\nclass D extends C {}\n\ + namespace C { export const x: 1 = 1; }\nconst n: 1 = D.x;\n", + "a base export reaches the descendant", + ); +} + +#[test] +fn an_early_constructor_alias_sees_later_namespace_exports() { + assert_reads_clean( + "class C {}\nclass D extends C {}\nconst alias = D;\n\ + namespace C { export const x = 1; }\nconst n: 1 = alias.x;\n", + "an alias shares the constructor's later namespace exports", + ); +} + +#[test] +fn an_own_static_colliding_with_its_own_namespace_is_a_duplicate() { + // The precedence rules must not silence a genuine collision on one class. + assert!( + !checker_codes("class C { static x: 1 = 1; }\nnamespace C { export const x = 2; }\n") + .is_empty(), + "a class static colliding with its own namespace export is a duplicate" + ); +} + +#[test] +fn an_early_constructor_alias_sees_later_namespace_exports_by_index() { + assert_reads_clean( + "class C {}\nclass D extends C {}\nconst alias = D;\n\ + namespace C { export const x: 1 = 1; }\nconst n: 1 = alias[\"x\"];\n", + "an alias shares the constructor's later namespace exports by index", + ); +} + +#[test] +fn an_early_constructor_alias_assigns_to_required_structural_type() { + assert_reads_clean( + "class C {}\nclass D extends C {}\nconst alias = D;\n\ + namespace C { export const x: 1 = 1; }\nconst obj: { x: 1 } = alias;\n", + "an alias is assignable to a structural type requiring a namespace export", + ); +} + +#[test] +#[ignore = "typeof-alias resolved before merge stays stale with and without the landed slot forward; tracks with representation fix"] +fn a_type_alias_captured_before_merge_sees_later_exports() { + assert_reads_clean( + "class C {}\nclass D extends C {}\ntype A = typeof D;\nconst force: A = D;\n\ + namespace C { export const x: 1 = 1; }\nconst probe: A = D;\nconst v: 1 = probe.x;\n", + "a type alias resolved before the merge shares later namespace exports", + ); +} + +// Pre-existing gap, not stale-alias specific: `keyof typeof D` fails even +// without an alias, so `keyof` on constructors needs its own fix. +#[test] +#[ignore = "keyof on constructors is unreduced even direct; tracks separately"] +fn an_early_constructor_alias_keyof_includes_later_namespace_exports() { + assert_reads_clean( + "class C {}\nclass D extends C {}\nconst alias = D;\n\ + namespace C { export const x: 1 = 1; }\ntype K = keyof typeof alias;\nconst k: K = \"x\";\n", + "an alias's keyof includes the constructor's later namespace exports", + ); +} + +// Pre-existing gap, not stale-alias specific: `new D(1)` fails even +// without an alias, so generic construct through a namespace-augmented base +// needs its own fix. +#[test] +#[ignore = "generic construct with namespace augmentation fails direct; tracks separately"] +fn a_generic_constructor_alias_keeps_construct_signatures_and_additions() { + assert_reads_clean( + "class C { constructor(public value: T) {} }\n\ + class D extends C {}\nconst alias = D;\n\ + namespace C { export const x: 1 = 1; }\n\ + const v: 1 = alias.x;\n\ + const n: number = new D(1).value;\n", + "a generic constructor alias keeps construct signatures and namespace exports", + ); +} + +#[test] +fn an_explicitly_structural_alias_does_not_gain_namespace_exports() { + assert!( + !checker_codes( + "class C {}\nclass D extends C {}\nconst alias: { prototype: C } = D;\n\ + namespace C { export const x: 1 = 1; }\nconst n: 1 = alias.x;\n" + ) + .is_empty(), + "a structural alias must not gain namespace exports" + ); +} diff --git a/crates/bamts-verification/src/check_cells.rs b/crates/bamts-verification/src/check_cells.rs index cabfa829..41386a0c 100644 --- a/crates/bamts-verification/src/check_cells.rs +++ b/crates/bamts-verification/src/check_cells.rs @@ -732,7 +732,7 @@ pub fn resolve_baseline_file( let owned: Vec<&(String, std::path::PathBuf, bool)> = matches .iter() .filter(|candidate| candidate.2) - .map(|candidate| *candidate) + .copied() .collect(); let stem_owned = !owned.is_empty() || variants.iter().any(|candidate| candidate.2) @@ -4145,6 +4145,78 @@ mod tests { .collect(); assert!(actual.is_empty(), "unexpected diagnostics: {codes:?}"); } + /// Regression: a derived own static survives a later base + /// namespace export with the same name (tsc silent: the override + /// is legal). Propagation must not report it as a collision. + #[test] + fn derived_own_static_survives_late_base_export() { + let case_text = "class C {\n}\nclass B extends C {\nstatic x: number = 2;\n}\nnamespace C {\nexport const x: number = 1;\n}\nconst n: number = B.x;\n"; + let units = split_case_units("tests/cases/compiler/tmAa.ts", case_text); + let entry = entry_virtual_path("tests/cases/compiler/tmAa.ts", &units); + let case = compile_case(&units, &entry).expect("case compiles"); + let code_map = repo_code_map(); + let mut actual = collect_facet_diagnostics(&case); + actual.retain(|diagnostic| code_map.get(&diagnostic.code).is_some()); + let codes: Vec<_> = actual + .iter() + .map(|d| (d.code.clone(), d.position.line)) + .collect(); + assert!(actual.is_empty(), "unexpected diagnostics: {codes:?}"); + } + /// Regression: an alias of a derived class exposes the derived + /// namespace export type, not a later-propagated base type (tsc + /// accepts `const n: 2 = alias.x`). + #[test] + fn derived_alias_exposes_derived_export_type() { + let case_text = "class C {\n}\nclass B extends C {\n}\nnamespace B {\nexport const x = 2;\n}\nnamespace C {\nexport const x: number = 1;\n}\nconst alias = B;\nconst n: 2 = alias.x;\n"; + let units = split_case_units("tests/cases/compiler/tmAb3.ts", case_text); + let entry = entry_virtual_path("tests/cases/compiler/tmAb3.ts", &units); + let case = compile_case(&units, &entry).expect("case compiles"); + let code_map = repo_code_map(); + let mut actual = collect_facet_diagnostics(&case); + actual.retain(|diagnostic| code_map.get(&diagnostic.code).is_some()); + let codes: Vec<_> = actual + .iter() + .map(|d| (d.code.clone(), d.position.line)) + .collect(); + assert!(actual.is_empty(), "unexpected diagnostics: {codes:?}"); + } + + /// Guard: direct access through a derived class keeps the + /// derived namespace export type (tsc accepts). + #[test] + fn derived_direct_access_keeps_derived_type() { + let case_text = "class C {\n}\nclass B extends C {\n}\nnamespace B {\nexport const x = 2;\n}\nnamespace C {\nexport const x: number = 1;\n}\nconst n: 2 = B.x;\n"; + let units = split_case_units("tests/cases/compiler/tmAb2.ts", case_text); + let entry = entry_virtual_path("tests/cases/compiler/tmAb2.ts", &units); + let case = compile_case(&units, &entry).expect("case compiles"); + let code_map = repo_code_map(); + let mut actual = collect_facet_diagnostics(&case); + actual.retain(|diagnostic| code_map.get(&diagnostic.code).is_some()); + let codes: Vec<_> = actual + .iter() + .map(|d| (d.code.clone(), d.position.line)) + .collect(); + assert!(actual.is_empty(), "unexpected diagnostics: {codes:?}"); + } + /// Regression: a computed numeric enum member earns the reverse + /// mapping (tsc silent: `E[0]` is `string`, as for constant + /// numeric members). + #[test] + fn computed_numeric_enum_reverse_mapping() { + let case_text = "enum E {\nA = Math.random()\n}\nconst s: string = E[0];\n"; + let units = split_case_units("tests/cases/compiler/tmAc.ts", case_text); + let entry = entry_virtual_path("tests/cases/compiler/tmAc.ts", &units); + let case = compile_case(&units, &entry).expect("case compiles"); + let code_map = repo_code_map(); + let mut actual = collect_facet_diagnostics(&case); + actual.retain(|diagnostic| code_map.get(&diagnostic.code).is_some()); + let codes: Vec<_> = actual + .iter() + .map(|d| (d.code.clone(), d.position.line)) + .collect(); + assert!(actual.is_empty(), "unexpected diagnostics: {codes:?}"); + } /// Regression: callable synthesis survives same-named type exports. /// `f.call` resolves through `Function.call` even with @@ -6055,16 +6127,25 @@ export const t = 1; ); } + /// The pinned authority test tree, resolved from this crate's location so + /// the tests do not depend on one developer's checkout. Overridable with + /// `BAMTS_AUTHORITY_ROOT` for a tree materialized elsewhere. + fn authority_tests_root() -> std::path::PathBuf { + match std::env::var("BAMTS_AUTHORITY_ROOT") { + Ok(root) => std::path::PathBuf::from(root), + Err(_) => Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/authority/typescript-7.0.2-tests"), + } + } + /// Run the 10 build-info evidence-sweep cells through the observer's core /// path: compile each case, emit build-info, extract `.tsbuildinfo` /// sections from the authority `.js` baseline, and compare. Reports /// per-cell PASS or BLOCKING_FAIL with the first differing line. #[test] fn build_info_ten_evidence_cells_per_cell_verdict() { - let authority = Path::new( - "/home/alpha/compiler/bamTiScript/target/authority/\ - typescript-7.0.2-tests", - ); + let authority = authority_tests_root(); + let authority = authority.as_path(); let cases: &[(&str, &str)] = &[ ( "incrementalConfig", @@ -6187,9 +6268,7 @@ export const t = 1; /// node can report before/after numbers. #[test] fn enum_types_facet_sample_60_cells() { - let authority_root = std::env::var("BAMTS_AUTHORITY_ROOT").unwrap_or_else(|_| { - "/home/alpha/compiler/bamTiScript/target/authority/typescript-7.0.2-tests".to_owned() - }); + let authority_root = authority_tests_root().to_string_lossy().into_owned(); let cases_dir = format!("{authority_root}/tests/cases/compiler"); let conformance_dir = format!("{authority_root}/tests/cases/conformance/enums"); let baseline_dir = format!("{authority_root}/tests/baselines/reference"); @@ -6282,12 +6361,7 @@ export const t = 1; /// report lands under the session scratch root. #[test] fn javascript_facet_first_delta_sample() { - let authority = - std::path::PathBuf::from(std::env::var("BAMTS_AUTHORITY_ROOT").unwrap_or_else(|_| { - "/home/alpha/compiler/bamTiScript/target/authority/\ - typescript-7.0.2-tests" - .to_owned() - })); + let authority = authority_tests_root(); let sample_cap: usize = std::env::var("BAMTS_JS_SAMPLE") .ok() .and_then(|value| value.parse().ok()) @@ -6563,9 +6637,7 @@ export const t = 1; /// records, counting verbatim line matches. #[test] fn enum_member_access_records_parity() { - let authority_root = std::env::var("BAMTS_AUTHORITY_ROOT").unwrap_or_else(|_| { - "/home/alpha/compiler/bamTiScript/target/authority/typescript-7.0.2-tests".to_owned() - }); + let authority_root = authority_tests_root().to_string_lossy().into_owned(); let baseline_dir = format!("{authority_root}/tests/baselines/reference"); let mut case_paths: Vec<(String, String)> = Vec::new(); for (rel_dir, dir) in [ diff --git a/crates/bamts-verification/src/corpus.rs b/crates/bamts-verification/src/corpus.rs index 3f82ef7c..a0e1a0b2 100644 --- a/crates/bamts-verification/src/corpus.rs +++ b/crates/bamts-verification/src/corpus.rs @@ -129,6 +129,11 @@ const READ_CHUNK: usize = 8192; const POLL_INTERVAL: Duration = Duration::from_millis(5); const NODE_VERSION_TIMEOUT: Duration = Duration::from_secs(10); const NODE_VERSION_OUTPUT_CAP: usize = 128; +/// Wall-clock bound for the AOT compile lane. A case timeout bounds the +/// compiled program, which is what the Node oracle measures, so native +/// code generation gets its own budget instead of eating the program's. +/// This still fails closed on a compiler that hangs. +const AOT_COMPILE_TIMEOUT: Duration = Duration::from_secs(120); const INTERPRETER_FUEL_PER_MILLISECOND: u64 = 10_000; const CORPUS_WORKER_REQUEST: &str = "BAMTS_CORPUS_WORKER_REQUEST"; const CORPUS_WORKER_TEST: &str = "corpus_differential_worker"; @@ -1234,13 +1239,9 @@ impl BamtsRunner { } fn run_aot(&self, spec: &CaseSpec) -> Result { - let started = Instant::now(); let artifacts = ArtifactDirectory::create(&self.root, spec, ExecutionMode::Aot) .map_err(|error| corpus_stage_error(CorpusStage::Link, error))?; let executable = artifacts.executable(spec); - let Some(compile_budget) = remaining_case_budget(spec.timeout(), started.elapsed()) else { - return Ok(timeout_outcome(Vec::new(), self.max_output_bytes)); - }; let request = WorkerRequest { root: self.root.clone(), spec: spec.clone(), @@ -1249,7 +1250,7 @@ impl BamtsRunner { executable: Some(executable.clone()), }; let (compile_stderr, compile_stderr_truncated) = - match run_worker(&artifacts, &request, compile_budget)? { + match run_worker(&artifacts, &request, AOT_COMPILE_TIMEOUT)? { WorkerRun::TimedOut(outcome) => return Ok(outcome), WorkerRun::Completed(WorkerResponse::Compile { stderr, @@ -1265,22 +1266,13 @@ impl BamtsRunner { )); } }; - let Some(execution_budget) = remaining_case_budget(spec.timeout(), started.elapsed()) - else { - return Ok(with_aot_compile_evidence( - timeout_outcome(Vec::new(), self.max_output_bytes), - compile_stderr, - compile_stderr_truncated, - self.max_output_bytes, - )); - }; let outcome = run_process( "BamTS AOT executable", &executable, &self.root, &normalized_env(), &[], - &aot_execution_limits(execution_budget, self.max_output_bytes), + &aot_execution_limits(spec.timeout(), self.max_output_bytes), ) .map_err(|error| corpus_stage_error(CorpusStage::Spawn, error))?; Ok(with_aot_compile_evidence( @@ -2924,7 +2916,7 @@ mod tests { } #[test] - fn aot_executable_uses_only_the_case_budget_remaining_after_compile() { + fn case_budget_shrinks_by_elapsed_time_and_closes_at_zero() { let total = Duration::from_millis(250); assert_eq!( @@ -2938,17 +2930,6 @@ mod tests { ); } - #[test] - fn aot_executable_preserves_output_limit_with_remaining_budget() { - let spec = aot_case("aot-budget", 250); - let remaining = remaining_case_budget(spec.timeout(), Duration::from_millis(123)) - .expect("compile has remaining budget"); - - let limits = aot_execution_limits(remaining, 123); - assert_eq!(limits.timeout, Duration::from_millis(127)); - assert_eq!(limits.max_output_bytes, 123); - } - #[test] fn live_aot_artifact_directories_never_overlap() { let root = scratch("aot-artifacts");