fix(transformer): replace ambient dot defines (#23231)

## Summary

Dot define replacement now treats ambient TypeScript declarations as replaceable global roots. This fixes cases like service worker code that declares `self` for type checking while still expecting `self.__WB_MANIFEST` to be replaced.

Before this change, dot defines only matched when the root identifier was unresolved/global:

```ts
foo.bar
```

would replace only if `foo` had no local symbol. In the bug case:

```ts
declare let self: ServiceWorkerGlobalScope;
precacheAndRoute(self.__WB_MANIFEST);
```

semantic analysis resolves `self` to the local `declare let self` symbol, so `ident.is_global_reference(scoping)` returned `false` and `self.__WB_MANIFEST` was treated as shadowed.

The new helper still allows true globals, but also allows symbols marked ambient:

```rust
.is_none_or(|symbol_id| scoping.symbol_flags(symbol_id).is_ambient())
```

That means:

- unresolved `self` -> replace
- `declare let self` -> replace, because it is TypeScript-only and not a runtime binding
- `let self` or function parameter `self` -> do not replace, because those are real runtime shadows

So this fixes the bug without making dot defines scope-insensitive.

Fixes #23224
This commit is contained in:
camc314 committed 2026-06-11 07:06:01 +00:00
1 parent 79cd905cd5
commit 52d0c31ed4
2 files changed
+62 -13

No files matched your search

@@ -7,7 +7,7 @@ use oxc_ast::ast::*;
use oxc_ast_visit::{VisitMut, walk_mut};
use oxc_diagnostics::{Diagnostics, OxcDiagnostic};
use oxc_parser::Parser;
use oxc_semantic::{IsGlobalReference, ReferenceFlags, ScopeFlags, Scoping};
use oxc_semantic::{ReferenceFlags, ScopeFlags, Scoping};
use oxc_span::{SPAN, SourceType};
use oxc_str::CompactStr;
use oxc_syntax::identifier::is_identifier_name;
@@ -399,18 +399,9 @@ impl<'a> ReplaceGlobalDefines<'a> {
&mut self,
ident: &oxc_allocator::Box<'_, IdentifierReference<'_>>,
) -> Option<Expression<'a>> {
let scoping = self.scoping();
if let Some(symbol_id) = ident
.reference_id
.get()
.and_then(|reference_id| scoping.get_reference(reference_id).symbol_id())
{
// Ignore `declare const IS_PROD: boolean;`
if !scoping.symbol_flags(symbol_id).is_ambient() {
return None;
}
if !Self::is_global_or_ambient_reference(self.scoping(), ident) {
return None;
}
// This is a global variable, including ambient variants such as `declare const`.
for (key, value) in &self.config.0.identifier.identifier_defines {
if ident.name.as_str() == key {
let value = value.clone();
@@ -789,7 +780,7 @@ impl<'a> ReplaceGlobalDefines<'a> {
})
}
Expression::Identifier(ident) => {
if !ident.is_global_reference(scoping) {
if !Self::is_global_or_ambient_reference(scoping, ident) {
return false;
}
cur_part_name = &ident.name;
@@ -863,6 +854,30 @@ impl<'a> ReplaceGlobalDefines<'a> {
obj.properties.retain(|_| *iter.next().unwrap());
expr
}
/// Return whether an identifier reference should be treated like a global define root.
///
/// Unresolved references are globals, so `foo.bar` can be replaced by a `foo.bar` define.
/// Ambient declarations are also replaceable because they are TypeScript-only and do not
/// create runtime bindings:
///
/// ```ts
/// declare let self: ServiceWorkerGlobalScope;
/// precacheAndRoute(self.__WB_MANIFEST); // replace `self.__WB_MANIFEST`
/// ```
///
/// Runtime bindings still shadow define roots:
///
/// ```ts
/// let self;
/// precacheAndRoute(self.__WB_MANIFEST); // do not replace
/// ```
fn is_global_or_ambient_reference(scoping: &Scoping, ident: &IdentifierReference<'_>) -> bool {
scoping
.get_reference(ident.reference_id())
.symbol_id()
.is_none_or(|symbol_id| scoping.symbol_flags(symbol_id).is_ambient())
}
}
#[derive(Debug, Clone, Copy)]
@@ -36,6 +36,25 @@ pub fn test(source_text: &str, expected: &str, config: &ReplaceGlobalDefinesConf
assert_eq!(result, expected, "for source {source_text}");
}
#[track_caller]
fn test_define_only(source_text: &str, expected: &str, config: &ReplaceGlobalDefinesConfig) {
let source_type = SourceType::ts().with_module(true);
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.diagnostics.is_empty());
let mut program = ret.program;
let scoping = SemanticBuilder::new().build(&program).semantic.into_scoping();
let ret = ReplaceGlobalDefines::new(&allocator, config.clone()).build(scoping, &mut program);
assert_eq!(ret.changed, source_text != expected);
AssertAst.visit_program(&program);
let result = Codegen::new()
.with_options(CodegenOptions { single_quote: true, ..CodegenOptions::default() })
.build(&program)
.code;
let expected = codegen(expected, source_type);
assert_eq!(result, expected, "for source {source_text}");
}
#[track_caller]
fn test_same(source_text: &str, config: &ReplaceGlobalDefinesConfig) {
test(source_text, source_text, config);
@@ -282,6 +301,21 @@ fn declare_const() {
test("declare const IS_PROD: boolean; if (IS_PROD) {} foo(IS_PROD)", "foo(true)", &config);
}
#[test]
fn declare_dot_define() {
let config = config(&[("process.env.NODE_ENV", "'production'")]);
test_define_then_transform_ts(
"declare let process: { env: { NODE_ENV: string } }; foo(process.env.NODE_ENV)",
"foo('production')",
&config,
);
test_define_only(
"declare let process: { env: { NODE_ENV: string } }; foo(process.env.NODE_ENV)",
"declare let process: { env: { NODE_ENV: string } }; foo('production')",
&config,
);
}
#[cfg(not(miri))]
#[test]
fn test_sourcemap() {