From e47e798d2776a275811074d29d0ed2e7d098099f Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:05:25 +0900 Subject: [PATCH 01/31] Refresh only inherited snapshots when propagating namespaces Base-namespace propagation passed base exports into descendants as own-namespace additions. A descendant-owned static then read as an own collision, reporting a false C001, and a descendant-namespace append was silently replaced, so an alias exposed the base type while direct access kept the derived one. Propagation now refreshes inherited snapshots only. Overrides and own appends win silently, tracked apart in their own set so a later base export still refreshes what it owns. --- crates/bamts-compiler/src/checker/binder.rs | 51 ++++++++++---- crates/bamts-verification/src/check_cells.rs | 72 ++++++++++++++++++++ 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 73f23bb7..67831d89 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -6065,6 +6065,11 @@ 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. + ns_propagated_statics: HashSet<(SymbolId, String)>, import_equals_symbols: HashMap, qualified_import_paths: HashMap>, import_equals_targets: HashMap, @@ -6287,6 +6292,7 @@ impl<'src> Binder<'src> { property_sites: Vec::new(), property_site_index: HashMap::new(), ns_appended_statics: HashSet::new(), + ns_propagated_statics: HashSet::new(), property_anchors: Vec::new(), property_anchor_index: HashMap::new(), literal_anchor: HashMap::new(), @@ -12411,7 +12417,7 @@ 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); // Derived constructors resolved before this augmentation // snapshotted the base statics: refresh them with the same // additions so late-merged members stay visible through @@ -12428,7 +12434,7 @@ impl<'src> Binder<'src> { if !visited.insert(derived) { continue; } - self.merge_ns_additions_into_static(derived, &additions); + self.merge_ns_additions_into_static(derived, &additions, false); stack.extend( self.class_base_symbols .iter() @@ -12443,11 +12449,15 @@ 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, ) { let Some(&existing) = self.class_constructor_types.get(&owner) else { return; @@ -12474,24 +12484,39 @@ 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())); + } 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 { + } 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. object.properties[index] = PropertyType::new(name.clone(), false, *type_id); - self.ns_appended_statics.insert((owner, name.clone())); + self.ns_propagated_statics.insert((owner, name.clone())); changed = true; } } diff --git a/crates/bamts-verification/src/check_cells.rs b/crates/bamts-verification/src/check_cells.rs index f271f2a0..d6710dfd 100644 --- a/crates/bamts-verification/src/check_cells.rs +++ b/crates/bamts-verification/src/check_cells.rs @@ -3988,6 +3988,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 From d2406167a364ee53ab3df952b78163c2c157bb90 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:06:27 +0900 Subject: [PATCH 02/31] Give computed numeric enum members a reverse mapping Reverse-mapping classification excluded every initializer it could not read as a plain numeric literal, so a computed member such as `enum E { A = Math.random() }` lost the index signature its runtime form carries. Only string-constant initializers are excluded now, since anything else tsc accepts in a numeric enum is numeric. --- crates/bamts-compiler/src/checker/binder.rs | 32 ++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 67831d89..d750f943 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -5461,6 +5461,20 @@ fn diagnostic_suppressions(source: &SourceFile) -> DiagnosticSuppressions { /// literals, parenthesized numerics, sign/bitwise-not applications, and /// numeric binary operators over numeric operands. Matches the runtime /// reverse-mapping rule without a full constant folder. +/// Whether an enum member initializer is a string constant: tsc only +/// accepts string-constant or numeric initializers, so anything else is +/// a computed numeric member with a runtime reverse mapping. +pub(crate) fn is_string_enum_initializer(expression: &Expr) -> bool { + match expression.data() { + Expression::Literal(Literal::String(_)) => true, + Expression::Parenthesized(inner) => is_string_enum_initializer(inner), + Expression::Binary(binary) if binary.operator == BinaryOperator::Add => { + is_string_enum_initializer(&binary.left) && is_string_enum_initializer(&binary.right) + } + _ => false, + } +} + pub(crate) fn is_numeric_enum_initializer(expression: &Expr) -> bool { match expression.data() { Expression::Literal(Literal::Number(_)) => true, @@ -10811,13 +10825,17 @@ impl<'src> Binder<'src> { }); // 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) - }) { + if declaration + .members + .iter() + .any(|member| match member.data().initializer.as_deref() { + None => true, + Some(initializer) => { + is_numeric_enum_initializer(initializer) + || !is_string_enum_initializer(initializer) + } + }) + { self.enum_has_numeric_member.insert(symbol); } match self.type_defs.get_mut(&symbol) { From ab463c1509408043c4dea6ebeb562259ff150a7d Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:58:49 +0900 Subject: [PATCH 03/31] Copy owned baseline matches without a closure Clippy `map_clone` rejects the explicit copying closure, and the Quality gate builds with `-D warnings`, so the workspace lint run failed on it. `matches` holds shared references, which are `Copy`, so `copied()` expresses the same step directly. --- crates/bamts-verification/src/check_cells.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bamts-verification/src/check_cells.rs b/crates/bamts-verification/src/check_cells.rs index ea379c28..d1368aa7 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) From 473a2a31c0cb30a6defc96f9802edba7b57ecf49 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:58:56 +0900 Subject: [PATCH 04/31] Fetch all-target crates before the offline ledger gate `ledger verify` runs `cargo metadata --locked --offline` inside `workspace_guard`, which resolves the dependency graph for every target. That graph reaches `android_system_properties`, an Android-only dependency of `iana-time-zone`, which the host build never downloads. The Formal job therefore failed with `E_TOOL_FAILED: cargo metadata exit status 101`. `cargo fetch` without `--target` populates the registry for all targets, so the later offline resolution finds every crate. --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/nightly.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35e11c6b..fc5631eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,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/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6ffa362c..cd02b0ee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -359,6 +359,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 From c93e13aaf9a5c5dc24c53c393df2a98b03f7e40e Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:01:20 +0900 Subject: [PATCH 05/31] Materialize the conformance authority before receipts `suite run` hashes the locked authority markers under target/authority, and the typescript-7.0.2 catalog reads both the compiler tree and the test tree. Neither conformance job fetched either tree, so every shard failed with `E_TOOL_MISSING: authority typescript-7.0.2 is not materialized`. The tool's own hint names the authority directory rather than the declared source, so it points at a source that does not exist. The compiler tree comes from `typescript-7-compiler` and the test tree from `typescript-primary-tests`, matching the fetch the Quality job already performs. --- .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/nightly.yml | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc5631eb..13e8c3cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,6 +234,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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index cd02b0ee..a1c2092b 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 From 0a1cb6e04eb842724eebd07c197e2c0834f52a09 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:08:40 +0900 Subject: [PATCH 06/31] Stop AOT compilation from consuming the case budget `run_aot` spent one `expected_timeout_ms` across both lanes: it gave the compile worker the whole case budget, then handed the compiled program whatever remained. A slow compile left almost nothing, so the program was killed before it printed anything and the case failed with `timed_out=true, stdout_len=0, stderr_len=0`. The victim cases moved from run to run, because the starvation follows compile duration rather than any property of the program. Locally the pinned corpus failed on `mitt` and `ufo`, while CI failed on `citty`; main reproduced the same class with the same counts. A case timeout bounds the program, which is what the Node oracle measures, so compilation now has its own explicit bound and the executable receives the full case timeout. A hung compiler still fails closed against that bound. `aot_executable_preserves_output_limit_with_remaining_budget` asserted the budget subtraction that no longer exists and is removed; `every_execution_mode_enforces_the_case_timeout` still proves an infinite loop cannot escape its case timeout in any mode. --- crates/bamts-verification/src/corpus.rs | 35 ++++++------------------- 1 file changed, 8 insertions(+), 27 deletions(-) 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"); From aafb3cd05627192d95c022f9b0e4496cee7ddcd7 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:34:55 +0900 Subject: [PATCH 07/31] Resolve the authority test tree from the crate location Four tests defaulted to an absolute path inside one developer's home directory, so they only ever found the authority tree on that machine. Everywhere else the directory read failed, the case list came back empty, and the sample assertions reported a missing authority instead of a real defect. The default now derives from `CARGO_MANIFEST_DIR`, which every checkout resolves correctly. `BAMTS_AUTHORITY_ROOT` still overrides it for a tree materialized somewhere else. --- crates/bamts-verification/src/check_cells.rs | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/bamts-verification/src/check_cells.rs b/crates/bamts-verification/src/check_cells.rs index d1368aa7..41386a0c 100644 --- a/crates/bamts-verification/src/check_cells.rs +++ b/crates/bamts-verification/src/check_cells.rs @@ -6127,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", @@ -6259,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"); @@ -6354,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()) @@ -6635,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 [ From 882a46bd0d67e7a46462a07aef4f729eb62781ef Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:34:55 +0900 Subject: [PATCH 08/31] Provision Node and Quint for the workspace test job The Quality job ran `cargo test --workspace` with no Node toolchain, no npm install, and no Quint. The authority and oracle-pin tests read `node_modules/typescript/package.json` and the formal bridge drives a local Quint, so seven tests failed on a missing prerequisite rather than on any defect. Clippy aborted the job before the test step until now, which is why this never surfaced. The sibling conformance and formal jobs already install exactly these prerequisites. --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13e8c3cd..23450b5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,22 @@ jobs: 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 From 45134927db05ae3290c6d4d610f381688ea578a1 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:23:14 +0900 Subject: [PATCH 09/31] Let the enum plan decide the reverse-mapping index The binder kept its own syntactic copy of the reverse-mapping rule and treated every initializer it could not recognize as numeric: is_numeric_enum_initializer(init) || !is_string_enum_initializer(init) `is_string_enum_initializer` accepted only string literals, parentheses, and `+` with both sides string. A reference to an earlier string member, a no-substitution template, and an `as` wrapper all fell through both predicates, so `enum E { A = "a", B = A }` gained a numeric index signature and wrongly accepted `E[0]` as `string`. The enum plan already decides this from the evaluated value together with a stronger syntactic test that unwraps transparent expressions and accepts templates. It is now the only authority: `bind_enum` keeps a provisional guess from numeric initializers alone, and `finish` reconciles that against the plan before the model is published, rebuilding a constructor only where the two disagree so untouched enums keep their exact type identity. Declarations merging onto one symbol union their reverse bit and rebuild once. `is_string_enum_initializer` had no other callers and is deleted, so the weaker rule cannot drift back in. Heterogeneous and auto-numbered enums are unchanged: `enum H { A = 1, B = "b" }` and `enum I { A, B }` keep the index signature. --- crates/bamts-compiler/src/checker/binder.rs | 154 +++++++++++++------- 1 file changed, 98 insertions(+), 56 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index b833acc3..dfd36ddc 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -5461,20 +5461,6 @@ fn diagnostic_suppressions(source: &SourceFile) -> DiagnosticSuppressions { /// literals, parenthesized numerics, sign/bitwise-not applications, and /// numeric binary operators over numeric operands. Matches the runtime /// reverse-mapping rule without a full constant folder. -/// Whether an enum member initializer is a string constant: tsc only -/// accepts string-constant or numeric initializers, so anything else is -/// a computed numeric member with a runtime reverse mapping. -pub(crate) fn is_string_enum_initializer(expression: &Expr) -> bool { - match expression.data() { - Expression::Literal(Literal::String(_)) => true, - Expression::Parenthesized(inner) => is_string_enum_initializer(inner), - Expression::Binary(binary) if binary.operator == BinaryOperator::Add => { - is_string_enum_initializer(&binary.left) && is_string_enum_initializer(&binary.right) - } - _ => false, - } -} - pub(crate) fn is_numeric_enum_initializer(expression: &Expr) -> bool { match expression.data() { Expression::Literal(Literal::Number(_)) => true, @@ -5509,6 +5495,48 @@ pub(crate) fn is_numeric_enum_initializer(expression: &Expr) -> bool { _ => 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. @@ -6112,6 +6140,12 @@ 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, + /// 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. @@ -6320,6 +6354,7 @@ impl<'src> Binder<'src> { 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(), class_constructor_types: HashMap::new(), imported_type_parameters: HashMap::new(), @@ -9464,6 +9499,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); @@ -9549,6 +9585,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, @@ -10953,35 +11016,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( @@ -11041,19 +11076,17 @@ 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| match member.data().initializer.as_deref() { - None => true, - Some(initializer) => { - is_numeric_enum_initializer(initializer) - || !is_string_enum_initializer(initializer) - } - }) - { + // Provisional reverse-map guess: an auto-numbered or + // numeric-literal member earns the index signature. The enum plan + // is the authority; `finish` reconciles this against it before + // the model is published. + if declaration.members.iter().any(|member| { + member + .data() + .initializer + .as_deref() + .is_none_or(is_numeric_enum_initializer) + }) { self.enum_has_numeric_member.insert(symbol); } match self.type_defs.get_mut(&symbol) { @@ -11092,6 +11125,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); @@ -12816,6 +12851,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); From 557ee0e5e78225a432c962bba86ba9ef25d37bd3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:49:41 +0900 Subject: [PATCH 10/31] Keep the nearer ancestor when propagating base statics Transitive propagation recorded only the leaf and the member name, so it could not tell which ancestor a refreshed snapshot came from. With `B extends C`, `D extends B`, `namespace B` exporting `x = 2`, and a later `namespace C` exporting `x: number = 1`, `D`'s snapshot of `B.x` carried neither `D`'s append marker nor `declaring_class() == D`, so propagating `C.x` replaced it. `D.x` then read as `number` and `const n: 2 = alias.x` was rejected with TYPE_NOT_ASSIGNABLE, although the nearer `B.x` must win. Propagated entries now record the originating ancestor and its depth from the leaf. The propagation arm replaces a snapshot only when the incoming ancestor is at the same or a nearer depth, so a farther ancestor cannot overwrite a nearer override while the same ancestor still refreshes as before. Own class statics and the descendant's own namespace appends keep their existing precedence, and direct additions keep their collision behavior. --- crates/bamts-compiler/src/checker/binder.rs | 47 ++++++++++++++------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index dfd36ddc..c08d984e 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -6110,8 +6110,11 @@ pub(crate) struct Binder<'src> { /// 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. - ns_propagated_statics: HashSet<(SymbolId, String)>, + /// 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, @@ -6343,13 +6346,13 @@ impl<'src> Binder<'src> { member_reference_recorded: HashSet::new(), property_sites: Vec::new(), property_site_index: HashMap::new(), - ns_appended_statics: HashSet::new(), - ns_propagated_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(), @@ -12688,28 +12691,28 @@ impl<'src> Binder<'src> { if additions.is_empty() { return; } - self.merge_ns_additions_into_static(symbol, &additions, true); + 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, false); + 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))), ); } } @@ -12729,6 +12732,8 @@ impl<'src> Binder<'src> { owner: SymbolId, additions: &[(String, TypeId, SymbolId)], direct: bool, + source: SymbolId, + depth: u32, ) { let Some(&existing) = self.class_constructor_types.get(&owner) else { return; @@ -12758,7 +12763,8 @@ impl<'src> Binder<'src> { if direct { self.ns_appended_statics.insert((owner, name.clone())); } else { - self.ns_propagated_statics.insert((owner, name.clone())); + self.ns_propagated_statics + .insert((owner, name.clone()), (source, depth)); } changed = true; } @@ -12785,10 +12791,21 @@ impl<'src> Binder<'src> { // 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. - object.properties[index] = PropertyType::new(name.clone(), false, *type_id); - self.ns_propagated_statics.insert((owner, name.clone())); - changed = true; + // appends win over later base exports. A nearer + // ancestor's value (smaller depth) takes precedence + // over a farther one; the same ancestor at the same + // depth still refreshes. + let key = (owner, name.clone()); + let refreshes = match self.ns_propagated_statics.get(&key) { + Some(&(_, recorded_depth)) => depth <= recorded_depth, + None => true, + }; + if refreshes { + object.properties[index] = + PropertyType::new(name.clone(), false, *type_id); + self.ns_propagated_statics.insert(key, (source, depth)); + changed = true; + } } } } From 6dafcf5b28c4bda4f4d28074402ae7e62b48feb8 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:54:50 +0900 Subject: [PATCH 11/31] Materialize the authority in the accounting jobs `suite merge` recomputes the run binding, and its authority digest reads the markers for both the compiler and the test tree. The receipts artifact carries only JSONL, so a clean accounting runner had neither directory and both merge steps failed with E_TOOL_MISSING even though the shard jobs succeeded. The same two fetches the shard jobs perform now run before the merge in the pull-request and nightly accounting jobs. `report-aarch64-accounting` merges the `test262` catalog, which reads a different authority directory. That job is skipped on pull requests, so it has produced no failure to act on and is left unchanged. --- .github/workflows/ci.yml | 11 +++++++++++ .github/workflows/nightly.yml | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23450b5b..c1dbdd60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,6 +319,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 a1c2092b..38c7ed90 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -160,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 From 851f88de16208757253c3bdd77589c6c7dd97b46 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:04:47 +0900 Subject: [PATCH 12/31] Decide the enum reverse map before members are typed The reconciliation added in 8af5c39 ran too late. Member accesses are typed during statement resolution, before `finish` reaches the enum plan, so `enum E { A = Math.random() }` was bound without the numeric index signature and `E[0]` reported BAMTS-C057 four times. Rebuilding the constructor afterwards could neither retract that diagnostic nor retype the aliases already bound to it, which regressed `computed_numeric_enum_reverse_mapping`. Post-hoc correction cannot work in either direction: a missing index signature leaves a false diagnostic behind, and a false one lets a string-only lookup pass while it is checked. So the decision now happens in `bind_enum`, where the constructor is first written. Syntax settles most of it, using the plan's rule rather than the weaker predicate this replaces: string literals, templates, transparent wrappers, and `+` where either side is a string. A bare reference to an earlier member of the same enum resolves against the members already walked, which covers `enum E { A = "a", B = A }`. A reference this cannot settle stays numeric, so an unsettled case keeps the index signature and never invents a diagnostic. The `finish` reconciliation stays as the plan's final say for merged declarations that bind before their facts exist; it is now a no-op for every enum in the suite. --- crates/bamts-compiler/src/checker/binder.rs | 99 ++++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index c08d984e..e82865a3 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -5495,6 +5495,33 @@ 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 @@ -11079,17 +11106,34 @@ impl<'src> Binder<'src> { .as_deref() .is_none_or(is_numeric_enum_initializer) }); - // Provisional reverse-map guess: an auto-numbered or - // numeric-literal member earns the index signature. The enum plan - // is the authority; `finish` reconciles this against it before - // the model is published. - 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. + let mut string_valued: HashSet = HashSet::new(); + 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, &string_valued) + { + string_valued.insert(name.to_utf8_lossy()); + } else { + has_numeric_member = true; + } + } + if has_numeric_member { self.enum_has_numeric_member.insert(symbol); } match self.type_defs.get_mut(&symbol) { @@ -23469,6 +23513,39 @@ impl<'src> Binder<'src> { _ => None, } } + + /// Whether an enum member initializer is a bare reference to an earlier + /// string-valued member of the same enum, as in `enum E { A = "a", B = A }`. + /// 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, + string_valued: &HashSet, + ) -> bool { + match expression.data() { + Expression::Identifier(identifier) => { + string_valued.contains(self.identifier_text(identifier).as_ref()) + } + Expression::Parenthesized(inner) => { + self.references_string_enum_member(inner, string_valued) + } + Expression::As(expression) => { + self.references_string_enum_member(&expression.expression, string_valued) + } + Expression::Satisfies(expression) => { + self.references_string_enum_member(&expression.expression, string_valued) + } + Expression::TypeAssertion(expression) => { + self.references_string_enum_member(&expression.expression, string_valued) + } + Expression::NonNull(expression) => { + self.references_string_enum_member(&expression.expression, string_valued) + } + _ => false, + } + } } /// A default zero range for synthesized diagnostics anchored on missing syntax. From eee4dbcd280db5099f118e87dd2ce7f0ed2d1836 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:47:29 +0900 Subject: [PATCH 13/31] Classify qualified and merged string enum references Reverse-map classification saw only bare identifiers within one declaration, so `enum E { A = "a", B = E.A }` and a second `enum E { B = A }` fragment both read as numeric and provisionally gained an index signature. `E[0]` then type-checked, and the plan reconciliation in `finish` runs after expression checking, so it cannot retract the accepted access. String-valued member names now persist per enum symbol across merged declarations, and the reference resolver accepts `E.A` and `E["A"]` alongside the bare name. A qualified reference through another enum stays numeric, so computed members keep their runtime reverse map. --- crates/bamts-compiler/src/checker/binder.rs | 44 ++++++++++++++++----- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index e82865a3..ab143834 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -6170,6 +6170,11 @@ 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 @@ -6386,6 +6391,7 @@ impl<'src> Binder<'src> { 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(), @@ -11115,7 +11121,11 @@ impl<'src> Binder<'src> { // 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. - let mut string_valued: HashSet = HashSet::new(); + let enum_name = self.identifier_text(&declaration.name).into_owned(); + 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 { @@ -11126,13 +11136,15 @@ impl<'src> Binder<'src> { continue; }; if is_syntactically_string_initializer(initializer) - || self.references_string_enum_member(initializer, &string_valued) + || self.references_string_enum_member(initializer, &enum_name, &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); } @@ -23514,34 +23526,48 @@ impl<'src> Binder<'src> { } } - /// Whether an enum member initializer is a bare reference to an earlier - /// string-valued member of the same enum, as in `enum E { A = "a", B = A }`. + /// Whether an enum member initializer references an earlier + /// string-valued member of the same enum, as a bare name in + /// `enum E { A = "a", B = A }` or qualified as `E.A` or `E["A"]`. /// 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, + enum_name: &str, string_valued: &HashSet, ) -> bool { match expression.data() { Expression::Identifier(identifier) => { string_valued.contains(self.identifier_text(identifier).as_ref()) } + // `E.A` and `E["A"]` name this enum's own member; any other + // object is a different enum this cannot settle at bind time. + Expression::Member(member) => { + let Expression::Identifier(object) = member.object.data() else { + return false; + }; + if self.identifier_text(object).as_ref() != enum_name { + return false; + } + enum_plan::cook_member_property_name(self.source, &member.property) + .is_some_and(|name| string_valued.contains(name.to_utf8_lossy().as_str())) + } Expression::Parenthesized(inner) => { - self.references_string_enum_member(inner, string_valued) + self.references_string_enum_member(inner, enum_name, string_valued) } Expression::As(expression) => { - self.references_string_enum_member(&expression.expression, string_valued) + self.references_string_enum_member(&expression.expression, enum_name, string_valued) } Expression::Satisfies(expression) => { - self.references_string_enum_member(&expression.expression, string_valued) + self.references_string_enum_member(&expression.expression, enum_name, string_valued) } Expression::TypeAssertion(expression) => { - self.references_string_enum_member(&expression.expression, string_valued) + self.references_string_enum_member(&expression.expression, enum_name, string_valued) } Expression::NonNull(expression) => { - self.references_string_enum_member(&expression.expression, string_valued) + self.references_string_enum_member(&expression.expression, enum_name, string_valued) } _ => false, } From c014440fe7d96c0c981932b81c4baffbfd8de21f Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:47:39 +0900 Subject: [PATCH 14/31] Rank inherited own statics by declaring depth The depth guard only ranked values a previous propagation pass had recorded. A static inherited as a nearer ancestor's own member carries no such record, so a farther ancestor's later namespace export overwrote it: with `B extends C` overriding `static x`, `D.x` read as C's type and a `const n: 2 = D.x` annotation failed with C004. Propagation now falls back to the existing property's declaring class and measures its inheritance distance from the owner. A nearer or equal ancestor keeps its value; an unrelated declaring class still refreshes, so the own-static and own-append arms are unchanged. --- crates/bamts-compiler/src/checker/binder.rs | 33 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index ab143834..244ac099 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -12772,6 +12772,25 @@ impl<'src> Binder<'src> { ); } } + + /// 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 + } + /// 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 @@ -12848,13 +12867,19 @@ impl<'src> Binder<'src> { // 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 (smaller depth) takes precedence - // over a farther one; the same ancestor at the same - // depth still refreshes. + // 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. let key = (owner, name.clone()); let refreshes = match self.ns_propagated_statics.get(&key) { Some(&(_, recorded_depth)) => depth <= recorded_depth, - None => true, + None => match object.properties[index].declaring_class() { + Some(declaring) => self + .inheritance_depth(owner, declaring) + .is_none_or(|inherited| depth <= inherited), + None => true, + }, }; if refreshes { object.properties[index] = From 162c1e61cb7d240077706c5d5609f41b94d766d3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:12:24 +0900 Subject: [PATCH 15/31] Resolve enum string references by symbol, not by name Reverse-map classification compared the qualified object against the enum's own name, so a string member reached through another enum stayed numeric: `enum F { A = "a" } enum E { B = F.A }` gave E an index signature and accepted `E[0]`. Concatenation had the same hole from the other side, since the reference resolver had no `+` traversal and `enum E { A = "a", B = A + A }` read as numeric. References now resolve the object to its enum symbol and ask that enum's member set, with the enum under construction answering from its in-progress set. One rule covers `E.A`, `E["A"]`, and `F.A`, and `+` yields a string when either side does, matching the syntactic classifier. An unresolvable object still stays numeric, so computed and cross-enum numeric members keep their runtime reverse map. --- crates/bamts-compiler/src/checker/binder.rs | 93 ++++++++++++++------- 1 file changed, 65 insertions(+), 28 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 244ac099..3f85136b 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -11121,7 +11121,8 @@ impl<'src> Binder<'src> { // 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. - let enum_name = self.identifier_text(&declaration.name).into_owned(); + // 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) @@ -11136,7 +11137,7 @@ impl<'src> Binder<'src> { continue; }; if is_syntactically_string_initializer(initializer) - || self.references_string_enum_member(initializer, &enum_name, &string_valued) + || self.references_string_enum_member(initializer, scope, symbol, &string_valued) { string_valued.insert(name.to_utf8_lossy()); } else { @@ -23551,49 +23552,85 @@ impl<'src> Binder<'src> { } } - /// Whether an enum member initializer references an earlier - /// string-valued member of the same enum, as a bare name in - /// `enum E { A = "a", B = A }` or qualified as `E.A` or `E["A"]`. - /// 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. + /// 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 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, - enum_name: &str, + 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` and `E["A"]` name this enum's own member; any other - // object is a different enum this cannot settle at bind time. + // `E.A`, `E["A"]`, and `F.A` are the same shape: resolve the + // object to its enum symbol, 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 Expression::Identifier(object) = member.object.data() else { return false; }; - if self.identifier_text(object).as_ref() != enum_name { + let Some(target) = self.lookup_value(scope, self.identifier_text(object).as_ref()) + else { return false; - } - enum_plan::cook_member_property_name(self.source, &member.property) - .is_some_and(|name| string_valued.contains(name.to_utf8_lossy().as_str())) - } - Expression::Parenthesized(inner) => { - self.references_string_enum_member(inner, enum_name, string_valued) - } - Expression::As(expression) => { - self.references_string_enum_member(&expression.expression, enum_name, string_valued) - } - Expression::Satisfies(expression) => { - self.references_string_enum_member(&expression.expression, enum_name, string_valued) + }; + 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())) + }) } - Expression::TypeAssertion(expression) => { - self.references_string_enum_member(&expression.expression, enum_name, string_valued) + // `+` 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::NonNull(expression) => { - self.references_string_enum_member(&expression.expression, enum_name, 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, } } From 487a279c6c022a6d26387e6d6742a1f664c154c0 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:12:35 +0900 Subject: [PATCH 16/31] Rank namespace-sourced statics by the inheritance chain The depth guard ranked a propagation record or a declaring class, and a value inherited from a base namespace has neither. A descendant prepared in a nested statement list after the nearer augmentation finished fell through to the unconditional refresh, so a farther base export overwrote it: with `namespace B { export const x = 2 }` and `class D extends B` inside another namespace, a later `namespace C { export const x: number = 1 }` made `D.x` read as `number`. Provenance for such a value now comes from the chain itself: the nearest strict ancestor whose own namespace appended the name. Records and declaring classes rank first where they exist, so the arm keeps one rule instead of a fallback that assumed no origin meant no owner. --- crates/bamts-compiler/src/checker/binder.rs | 38 +++++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 3f85136b..b17e7b0d 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -12792,6 +12792,27 @@ impl<'src> Binder<'src> { 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 @@ -12871,16 +12892,19 @@ impl<'src> Binder<'src> { // 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. + // 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 => match object.properties[index].declaring_class() { - Some(declaring) => self - .inheritance_depth(owner, declaring) - .is_none_or(|inherited| depth <= inherited), - None => true, - }, + 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] = From 4022668bc8a75ed5b0d90c343d4963ec4ef8a40f Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:12:47 +0900 Subject: [PATCH 17/31] Let exit end the api loop, not shutdown One flag carried two meanings. `shutdown` and `exit` both set `stopped`, and the serve loop breaks on it, so a `shutdown` request ended the loop while the reader was still parsing the next frame. That request left without the terminal response the protocol owes it, and whether it did depended on thread timing. Shutdown and exit are now separate states. `shutdown` marks the session as refusing further work and answers every later request as cancelled; only `exit` and end of input stop the loop. Work already in the transport is therefore read, admitted, and answered on every transport, without depending on how the reader is scheduled. --- crates/bamts-cli/src/api_server/session.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/bamts-cli/src/api_server/session.rs b/crates/bamts-cli/src/api_server/session.rs index b656d6c9..bd540b0b 100644 --- a/crates/bamts-cli/src/api_server/session.rs +++ b/crates/bamts-cli/src/api_server/session.rs @@ -78,6 +78,11 @@ pub(crate) struct Planned { pub struct Session { compiler: Option, + /// Set by `shutdown`: the session refuses further requests but the + /// loop keeps serving, so work already in the transport still gets + /// its terminal response. + shutting_down: bool, + /// Set by `exit` alone: the loop terminates. stopped: bool, } @@ -94,11 +99,13 @@ impl Session { pub fn new() -> Self { Self { compiler: None, + shutting_down: false, stopped: false, } } - /// Whether the loop should stop after the current dispatch batch. + /// Whether `exit` has been seen and the loop should terminate. + /// `shutdown` does not end the loop; only `exit` and end of input do. #[must_use] pub const fn stopped(&self) -> bool { self.stopped @@ -156,10 +163,16 @@ impl Session { params: Option<&Value>, cancellation: &CancellationToken, ) -> Result { + // After `shutdown` the session serves nothing further, but it + // still answers: a refusal is a terminal response, and the loop + // runs until `exit` or end of input. + if self.shutting_down { + return Err(ApiError::Cancelled); + } match method { "initialize" => self.initialize(params), "shutdown" => { - self.stopped = true; + self.shutting_down = true; Ok(Value::Null) } "service/open" => self.open(params, cancellation), From d13ba2664622fff86c6409fb6db134aac176f793 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:12:48 +0900 Subject: [PATCH 18/31] Time link cancellation from the cancel, not the spawn The cancelling thread gave the linker two seconds of wall clock to create its start marker, then asserted the marker existed. A loaded runner needs longer to spawn the process, so the assertion failed on timing rather than on behavior, and the boundedness check measured process startup along with cancellation. The thread now waits for the marker under a cap that only breaks a genuine hang, and returns the instant it cancelled. The bound measures from that instant, so it checks cancellation latency and nothing else. --- crates/bamts-cli/src/driver.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) 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)?; From f5d96ac4082e52001740555e4d2aa2abc32b050a Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:18:00 +0900 Subject: [PATCH 19/31] Guard enum reverse-mapping classification with tests Four reviewers found four holes in this classification in two rounds, each proved with a throwaway probe that then went away with the fix. The contract deserves a permanent guard. The cases assert what a consumer observes: a string enum rejects `E[0]` and a numeric one accepts it, across literals, transparent wrappers, bare and qualified references, cross-enum references, merged declarations, and concatenation. `string_members_reject_a_numeric_lookup` fails against the pre-fix binder. --- .../tests/enum_reverse_mapping.rs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 crates/bamts-compiler/tests/enum_reverse_mapping.rs 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..8b15168d --- /dev/null +++ b/crates/bamts-compiler/tests/enum_reverse_mapping.rs @@ -0,0 +1,95 @@ +//! 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}, +}; + +/// 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() +} + +fn rejects_numeric_lookup(source: &str) -> bool { + !checker_codes(source).is_empty() +} + +#[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. + "enum F { A = \"a\" }\nenum E { B = F.A }\nconst v = E[0];\n", + // A second declaration continues the first one's classification. + "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", + ] { + assert!( + rejects_numeric_lookup(source), + "a string enum has no reverse mapping: {source}" + ); + } +} + +#[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", + // One numeric member is enough to earn the index signature. + "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" + ); +} From 7d1209a6b0f6dbdd6ec2e94e1cd039e093a9940d Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:18:00 +0900 Subject: [PATCH 20/31] Guard namespace static precedence with tests Namespace-to-class static precedence produced three separate review findings, so pin the rule rather than the last instance of it. The cases assert the type a member access reads: a descendant's own static outranks a base export, a nearer ancestor outranks a farther one whether the value came from a static or a namespace, a late descendant keeps its nearer origin, the supplying ancestor still refreshes, and a genuine same-class collision is still reported. `a_descendant_declared_late_keeps_its_nearer_origin` fails against the pre-fix binder. --- .../tests/namespace_static_inheritance.rs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 crates/bamts-compiler/tests/namespace_static_inheritance.rs 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..e7dd4e52 --- /dev/null +++ b/crates/bamts-compiler/tests/namespace_static_inheritance.rs @@ -0,0 +1,103 @@ +//! 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_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" + ); +} From 4a1e9c654dc7e098af5af1a8570101f3ae97b128 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:43:51 +0900 Subject: [PATCH 21/31] Reap the reader before draining, keeping shutdown terminal Commit 160d178 made `exit` the only way to end the api loop, on the reading that one `stopped` flag serving both `shutdown` and `exit` was a design fault. That was wrong: `api_transport_serves_socket_backed_stdin` states the contract directly, requiring the child to exit because shutdown was requested and its reader reaped rather than because stdin reached EOF. Redefining that contract hung the socket transport for the test's full 120 seconds. Shutdown stays terminal. The actual defect was narrower: the loop could break while the reader was still parsing the next frame, so a request already in the transport left without the terminal response the drain owes it, decided only by thread scheduling. Reaping the reader before the drain makes that set well defined. A reader that cannot be woken keeps its existing orphan path, so stdio shutdown is unchanged. --- crates/bamts-cli/src/api_server/mod.rs | 10 ++++++++++ crates/bamts-cli/src/api_server/session.rs | 17 ++--------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/bamts-cli/src/api_server/mod.rs b/crates/bamts-cli/src/api_server/mod.rs index e3d9b1a5..9a8fa64d 100644 --- a/crates/bamts-cli/src/api_server/mod.rs +++ b/crates/bamts-cli/src/api_server/mod.rs @@ -145,6 +145,16 @@ 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. A reader + // that cannot be woken keeps the existing orphan path rather than + // stalling shutdown for the reap deadline. + if I::Waker::REAPABLE { + control.wait_reaped(REAP_DEADLINE); + } for inbound in control.drain() { match inbound { Inbound::Work { diff --git a/crates/bamts-cli/src/api_server/session.rs b/crates/bamts-cli/src/api_server/session.rs index bd540b0b..b656d6c9 100644 --- a/crates/bamts-cli/src/api_server/session.rs +++ b/crates/bamts-cli/src/api_server/session.rs @@ -78,11 +78,6 @@ pub(crate) struct Planned { pub struct Session { compiler: Option, - /// Set by `shutdown`: the session refuses further requests but the - /// loop keeps serving, so work already in the transport still gets - /// its terminal response. - shutting_down: bool, - /// Set by `exit` alone: the loop terminates. stopped: bool, } @@ -99,13 +94,11 @@ impl Session { pub fn new() -> Self { Self { compiler: None, - shutting_down: false, stopped: false, } } - /// Whether `exit` has been seen and the loop should terminate. - /// `shutdown` does not end the loop; only `exit` and end of input do. + /// Whether the loop should stop after the current dispatch batch. #[must_use] pub const fn stopped(&self) -> bool { self.stopped @@ -163,16 +156,10 @@ impl Session { params: Option<&Value>, cancellation: &CancellationToken, ) -> Result { - // After `shutdown` the session serves nothing further, but it - // still answers: a refusal is a terminal response, and the loop - // runs until `exit` or end of input. - if self.shutting_down { - return Err(ApiError::Cancelled); - } match method { "initialize" => self.initialize(params), "shutdown" => { - self.shutting_down = true; + self.stopped = true; Ok(Value::Null) } "service/open" => self.open(params, cancellation), From a275a49706dfd30ab958253cebcc4e071f2062e0 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:07:37 +0900 Subject: [PATCH 22/31] Fetch all-target crates before the workspace guard `cargo test --workspace` reaches the workspace guard, which shells out to `cargo metadata --offline`. That resolves the dependency graph for every target, so it needs crates a host build never downloads, and the Quality job failed on `android_system_properties v0.1.6` with "attempting to make an HTTP request, but --offline was specified". This is the same mechanism 440e8bc fixed for the Formal job's G0 ledger, so it takes the same step. Only this job runs the workspace test, so no other workflow needs it. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1dbdd60..5438ed6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,13 @@ 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 From 2a6d3db718803496531e0e88fb4541469272ca09 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:18:55 +0900 Subject: [PATCH 23/31] Resolve enum owners reached through a namespace The member arm required the access object to be a bare identifier, so a namespace-qualified owner was rejected outright. For `namespace N { export enum F { A = "a" } } enum E { B = N.F.A }` the object is itself a member expression, so `B` classified as numeric and `E` gained an index signature its emitted form does not carry. Owner resolution is now recursive, walking each container's member scope, so `F` and `N.F` at any nesting are one rule rather than a supported case and a rejected one. An owner this file cannot see still resolves to nothing and leaves the member numeric. --- crates/bamts-compiler/src/checker/binder.rs | 40 ++++++++++++++----- .../tests/enum_reverse_mapping.rs | 5 ++- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index b17e7b0d..c299829f 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -23576,10 +23576,33 @@ impl<'src> Binder<'src> { } } + /// 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)?; + let MemberProperty::Named(name) = &member.property else { + return None; + }; + self.scopes[member_scope.0 as usize].value(self.identifier_text(name).as_ref()) + } + _ => 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 a concatenation of + /// 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 @@ -23595,16 +23618,13 @@ impl<'src> Binder<'src> { Expression::Identifier(identifier) => { string_valued.contains(self.identifier_text(identifier).as_ref()) } - // `E.A`, `E["A"]`, and `F.A` are the same shape: resolve the - // object to its enum symbol, 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. + // `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 Expression::Identifier(object) = member.object.data() else { - return false; - }; - let Some(target) = self.lookup_value(scope, self.identifier_text(object).as_ref()) - else { + let Some(target) = self.enum_owner_symbol(&member.object, scope) else { return false; }; let members = if target == owner { diff --git a/crates/bamts-compiler/tests/enum_reverse_mapping.rs b/crates/bamts-compiler/tests/enum_reverse_mapping.rs index 8b15168d..3caf1460 100644 --- a/crates/bamts-compiler/tests/enum_reverse_mapping.rs +++ b/crates/bamts-compiler/tests/enum_reverse_mapping.rs @@ -44,8 +44,10 @@ fn string_members_reject_a_numeric_lookup() { "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. + // 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", // A second declaration continues the first one's classification. "enum E { A = \"a\" }\nenum E { B = A }\nconst v = E[0];\n", // Concatenation is a string when either side is. @@ -71,6 +73,7 @@ fn numeric_members_keep_their_reverse_mapping() { // 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", // One numeric member is enough to earn the index signature. "enum E { A = \"a\", B = 1 }\nconst v = E[0];\n", ] { From 94d558e8343fff552103046adf650d27d2c9f7f7 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:24:37 +0900 Subject: [PATCH 24/31] Wait for the reader once during shutdown The pre-drain reap wait and the pre-join wait each started their own deadline, so a reapable reader that stayed blocked after the wake cost two consecutive five-second waits, and queued requests could not receive their drain responses until the first elapsed. One wait now serves both: its result decides whether the reader is joined or orphaned. A reader that never exits costs the deadline once, which is also what the code did before the pre-drain wait existed. --- crates/bamts-cli/src/api_server/mod.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/bamts-cli/src/api_server/mod.rs b/crates/bamts-cli/src/api_server/mod.rs index 9a8fa64d..7a7a9697 100644 --- a/crates/bamts-cli/src/api_server/mod.rs +++ b/crates/bamts-cli/src/api_server/mod.rs @@ -149,12 +149,11 @@ where // 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. A reader - // that cannot be woken keeps the existing orphan path rather than - // stalling shutdown for the reap deadline. - if I::Waker::REAPABLE { - control.wait_reaped(REAP_DEADLINE); - } + // 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 { @@ -178,7 +177,7 @@ where } } - let reaped = if I::Waker::REAPABLE && control.wait_reaped(REAP_DEADLINE) { + let reaped = if reader_reaped { Reaped::Joined( reader .join() From 8dca6179448f5d29b3bb0bcd453e3992ca7ca149 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:28:29 +0900 Subject: [PATCH 25/31] Read intermediate enum-path segments like the final one Owner resolution matched only a named property on intermediate segments, while the final member lookup already cooked named and constant computed properties. So `N.F.A` resolved and `N["F"].A` did not, classifying the member numeric and giving the enum an index signature its emitted form does not carry. Both segments now read through the same property cooker, so the path has one rule instead of a named case and a rejected one. --- crates/bamts-compiler/src/checker/binder.rs | 8 ++++---- crates/bamts-compiler/tests/enum_reverse_mapping.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index c299829f..7d9c2afa 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -23589,10 +23589,10 @@ impl<'src> Binder<'src> { Expression::Member(member) => { let container = self.enum_owner_symbol(&member.object, scope)?; let member_scope = self.container_member_scope(container)?; - let MemberProperty::Named(name) = &member.property else { - return None; - }; - self.scopes[member_scope.0 as usize].value(self.identifier_text(name).as_ref()) + // 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, } diff --git a/crates/bamts-compiler/tests/enum_reverse_mapping.rs b/crates/bamts-compiler/tests/enum_reverse_mapping.rs index 3caf1460..1ec1911b 100644 --- a/crates/bamts-compiler/tests/enum_reverse_mapping.rs +++ b/crates/bamts-compiler/tests/enum_reverse_mapping.rs @@ -48,7 +48,7 @@ fn string_members_reject_a_numeric_lookup() { // 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", - // A second declaration continues the first one's classification. + "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", @@ -74,7 +74,7 @@ fn numeric_members_keep_their_reverse_mapping() { "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", - // One numeric member is enough to earn the index signature. + "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!( From 6c300f897814aff4ef37da345c5eebc565dac9d3 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:32:50 +0900 Subject: [PATCH 26/31] Surface a reader that fails during the drain The reap result was sampled before the drain, so a reader that exited while cancellation responses were being written was still orphaned. Its handle was dropped without a join, and `serve_reaped` returned success even though the reader had failed or panicked. The state is re-read once the drain finishes, with a zero deadline so it reads rather than waits. The single bounded wait above is unchanged, and a late reader failure now reaches the caller. --- crates/bamts-cli/src/api_server/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/bamts-cli/src/api_server/mod.rs b/crates/bamts-cli/src/api_server/mod.rs index 7a7a9697..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; @@ -177,6 +178,13 @@ where } } + // 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 From b168bfb727aa1e69cedf4725b9d10764ccc31ccd Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:10:44 +0900 Subject: [PATCH 27/31] Decode escaped names before member lookup Member accesses used raw token spelling while declarations used decoded identifier names. Escaped names therefore missed both property lookup and enum classification, incorrectly allowing numeric reverse lookup. Use the existing identifier decoder at the shared property-name boundary. Regression tests distinguish the reverse-lookup diagnostic by its source range and require valid escaped references to resolve without errors. --- crates/bamts-compiler/src/enum_plan.rs | 4 +- .../tests/enum_reverse_mapping.rs | 123 ++++++++++++++++-- 2 files changed, 112 insertions(+), 15 deletions(-) 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 index 1ec1911b..43b0b1f6 100644 --- a/crates/bamts-compiler/tests/enum_reverse_mapping.rs +++ b/crates/bamts-compiler/tests/enum_reverse_mapping.rs @@ -9,27 +9,123 @@ use std::sync::Arc; use bamts_compiler::{ checker::check, parser, scanner, - source::{ScriptKind, SourceId, SourceText}, + source::{ScriptKind, SourceId, SourceText, TextRange}, }; /// 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")), - ); +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() - .map(|diagnostic| diagnostic.code().as_str().to_owned()) - .filter(|code| code.starts_with("BAMTS-C")) + .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() } -fn rejects_numeric_lookup(source: &str) -> bool { - !checker_codes(source).is_empty() +#[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] @@ -54,9 +150,10 @@ fn string_members_reject_a_numeric_lookup() { "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!( - rejects_numeric_lookup(source), - "a string enum has no reverse mapping: {source}" + has_code_at(&diagnostics, "BAMTS-C057", e_index_key_range(source)), + "a string enum has no reverse mapping: {source}\n{diagnostics:?}" ); } } From 300ee6b2643202642f6a556a30b2fe98035ed290 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:56:06 +0900 Subject: [PATCH 28/31] Refresh captured constructors on namespace merge Aliases stored the pre-merge constructor id, so a later base export stayed invisible through the alias while direct access saw it. Forward exact top-level matches in symbol and node types to the current id; reassigned bindings hold a different id and stay untouched. Nested interned captures need a representation fix tracked separately. --- crates/bamts-compiler/src/checker/binder.rs | 24 +++++-- .../tests/namespace_static_inheritance.rs | 67 +++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 7d9c2afa..f62b2c0a 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -12916,11 +12916,27 @@ impl<'src> Binder<'src> { } } } - 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 value 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. 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); } fn finalize_namespace_constructor(&mut self, statement_id: NodeId) { diff --git a/crates/bamts-compiler/tests/namespace_static_inheritance.rs b/crates/bamts-compiler/tests/namespace_static_inheritance.rs index e7dd4e52..0a2c0341 100644 --- a/crates/bamts-compiler/tests/namespace_static_inheritance.rs +++ b/crates/bamts-compiler/tests/namespace_static_inheritance.rs @@ -92,6 +92,15 @@ fn a_base_export_still_refreshes_an_inherited_snapshot() { ); } +#[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. @@ -101,3 +110,61 @@ fn an_own_static_colliding_with_its_own_namespace_is_a_duplicate() { "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", + ); +} + +// 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" + ); +} From 67488b8f71049e6f86422a527af0df838303bb58 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:02:13 +0900 Subject: [PATCH 29/31] Forward type-state cache on namespace merge resolve_import_equals_type_symbol and resolve_type_symbol early-return Done ids forever, so a typeof alias resolved before the merge keeps serving the stale id. Extend the exact-ID forward to type_state alongside symbol and node types. A typeof alias forced before the merge still needs a representation fix, so it stays ignored with its root-cause comment. --- crates/bamts-compiler/src/checker/binder.rs | 9 ++++++++- .../tests/namespace_static_inheritance.rs | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index f62b2c0a..20d28151 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -12925,7 +12925,7 @@ impl<'src> Binder<'src> { self.refresh_captured_constructor_views(existing, constructor); } - /// Refresh value views captured before a namespace augmentation. + /// 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 @@ -12937,6 +12937,13 @@ impl<'src> Binder<'src> { .chain(self.node_types.values_mut()) .filter(|ty| **ty == existing) .for_each(|ty| *ty = current); + self.type_state + .iter_mut() + .filter_map(|state| match state { + TypeState::Done(id) if *id == existing => Some(state), + _ => None, + }) + .for_each(|state| *state = TypeState::Done(current)); } fn finalize_namespace_constructor(&mut self, statement_id: NodeId) { diff --git a/crates/bamts-compiler/tests/namespace_static_inheritance.rs b/crates/bamts-compiler/tests/namespace_static_inheritance.rs index 0a2c0341..87454565 100644 --- a/crates/bamts-compiler/tests/namespace_static_inheritance.rs +++ b/crates/bamts-compiler/tests/namespace_static_inheritance.rs @@ -129,6 +129,16 @@ fn an_early_constructor_alias_assigns_to_required_structural_type() { ); } +#[test] +#[ignore = "type-state forward does not cover typeof-alias resolved before merge; 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] From 5d57c2562048ff0daba879811b4b9a82180f24b8 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:06:47 +0900 Subject: [PATCH 30/31] Advance baseline records on namespace merge typed_expressions feeds the .types emitter and records alongside node_types on first-seen, so exact pre-merge matches advance with the semantic slots. Same exact-ID forward, no semantic change. --- crates/bamts-compiler/src/checker/binder.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 20d28151..2526004c 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -12929,8 +12929,10 @@ impl<'src> Binder<'src> { /// `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. Captures nested inside - /// interned types need a representation fix tracked separately. + /// 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() @@ -12944,6 +12946,10 @@ impl<'src> Binder<'src> { _ => None, }) .for_each(|state| *state = TypeState::Done(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) { From 5328cd3dbcd0860c3581dca2d58b7e495746463e Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:09:34 +0900 Subject: [PATCH 31/31] Narrow refresh to proven capture slots The type_state sweep had no red-green proof: the typeof-alias probe fails with and without it. Keep the proven symbol, node, and baseline forwards; leave nested and typeof-alias captures to the tracked representation fix. --- crates/bamts-compiler/src/checker/binder.rs | 7 ------- .../bamts-compiler/tests/namespace_static_inheritance.rs | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/bamts-compiler/src/checker/binder.rs b/crates/bamts-compiler/src/checker/binder.rs index 2526004c..532de8a5 100644 --- a/crates/bamts-compiler/src/checker/binder.rs +++ b/crates/bamts-compiler/src/checker/binder.rs @@ -12939,13 +12939,6 @@ impl<'src> Binder<'src> { .chain(self.node_types.values_mut()) .filter(|ty| **ty == existing) .for_each(|ty| *ty = current); - self.type_state - .iter_mut() - .filter_map(|state| match state { - TypeState::Done(id) if *id == existing => Some(state), - _ => None, - }) - .for_each(|state| *state = TypeState::Done(current)); self.typed_expressions .iter_mut() .filter(|entry| entry.1 == existing) diff --git a/crates/bamts-compiler/tests/namespace_static_inheritance.rs b/crates/bamts-compiler/tests/namespace_static_inheritance.rs index 87454565..61936708 100644 --- a/crates/bamts-compiler/tests/namespace_static_inheritance.rs +++ b/crates/bamts-compiler/tests/namespace_static_inheritance.rs @@ -130,7 +130,7 @@ fn an_early_constructor_alias_assigns_to_required_structural_type() { } #[test] -#[ignore = "type-state forward does not cover typeof-alias resolved before merge; tracks with representation fix"] +#[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\