diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 1f4678fa6c28..ff20e67946f1 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -573,8 +573,8 @@ impl<'a> Transpiler<'a> { if self.options.transform_options.jsx.is_none() { self.options.jsx = jsx_pragma_from_resolver(&tsconfig.jsx); } - self.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata; - self.options.experimental_decorators = tsconfig.experimental_decorators; + self.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata(); + self.options.experimental_decorators = tsconfig.experimental_decorators(); if let Some(v) = tsconfig.use_define_for_class_fields { self.options.use_define_for_class_fields = v; } @@ -834,8 +834,8 @@ impl ParseOptions<'_, '_> { development: self.jsx.development, ..Default::default() }); - self.emit_decorator_metadata = own.emit_decorator_metadata; - self.experimental_decorators = own.experimental_decorators; + self.emit_decorator_metadata = own.emit_decorator_metadata(); + self.experimental_decorators = own.experimental_decorators(); self.use_define_for_class_fields = own.use_define_for_class_fields != Some(false); } } diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index e8f7f075939e..b2f0895dd7b7 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1946,18 +1946,18 @@ impl<'a> Resolver<'a> { development: result.jsx.development, ..Default::default() }); - (result.flags).set_emit_decorator_metadata(own.emit_decorator_metadata); - (result.flags).set_experimental_decorators(own.experimental_decorators); + (result.flags).set_emit_decorator_metadata(own.emit_decorator_metadata()); + (result.flags).set_experimental_decorators(own.experimental_decorators()); (result.flags).set_use_define_for_class_fields( own.use_define_for_class_fields != Some(false), ); } else if let Some(tsconfig) = self.enclosing_tsconfig_json(&dir) { result.jsx = tsconfig.merge_jsx(core::mem::take(&mut result.jsx)); result.flags.set_emit_decorator_metadata( - result.flags.emit_decorator_metadata() || tsconfig.emit_decorator_metadata, + result.flags.emit_decorator_metadata() || tsconfig.emit_decorator_metadata(), ); result.flags.set_experimental_decorators( - result.flags.experimental_decorators() || tsconfig.experimental_decorators, + result.flags.experimental_decorators() || tsconfig.experimental_decorators(), ); if let Some(v) = tsconfig.use_define_for_class_fields { result.flags.set_use_define_for_class_fields(v); @@ -6839,47 +6839,11 @@ impl<'a> Resolver<'a> { } } + // `current` is the base of the chain. Each pop is the next config that extends it. let mut merged_config = current; - // starting from the base config (end of the list) - // successively apply the inheritable attributes to the next config - while let Some(mut parent_config) = parent_configs.pop() { - let mc = &mut *merged_config; - mc.emit_decorator_metadata = - mc.emit_decorator_metadata || parent_config.emit_decorator_metadata; - if let Some(v) = parent_config.use_define_for_class_fields { - mc.use_define_for_class_fields = Some(v); - } - if !parent_config.base_url.is_empty() { - mc.base_url = core::mem::take(&mut parent_config.base_url); - } - mc.jsx = parent_config.merge_jsx(mc.jsx.clone()); - mc.jsx_flags.insert_all(parent_config.jsx_flags); - - if let Some(value) = parent_config.preserve_imports_not_used_as_values { - mc.preserve_imports_not_used_as_values = Some(value); - } - - // TypeScript replaces paths across extends (child overrides parent - // entirely), so when a more-specific config defines paths, replace - // rather than merge. base_url_for_paths is set whenever the paths - // key is present in the JSON (even if empty), so it discriminates - // "not defined" from "defined as {}" — the latter clears inherited - // paths per TypeScript semantics. - if !parent_config.base_url_for_paths.is_empty() { - // The previous merged_config.paths is being replaced; - // dropping the map frees the values automatically, so the - // PathsMap from the deeper config doesn't leak. - mc.paths = core::mem::take(&mut parent_config.paths); - mc.base_url_for_paths = core::mem::take(&mut parent_config.base_url_for_paths); - } else { - // paths were not moved to merged_config, so they're still owned - // by parent_config. base_url_for_paths.len == 0 implies the map - // is empty (it's only set when the `paths` key is present in the - // JSON), so this is a no-op but documents the ownership. - // (Drop handles parent_config.paths.) - } - // `destroy` logs what tsconfig-extends-leak.test.ts counts. - TSConfigJSON::destroy(parent_config); + while let Some(mut extending_config) = parent_configs.pop() { + extending_config.inherit(merged_config); + merged_config = extending_config; } return Ok(Some(merged_config)); } diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index c265d902dc97..c96f86c5b930 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -159,8 +159,10 @@ pub struct TSConfigJSON { pub(crate) preserve_imports_not_used_as_values: Option, - pub emit_decorator_metadata: bool, - pub experimental_decorators: bool, + /// `None` = unset. + pub(crate) emit_decorator_metadata: Option, + /// `None` = unset. + pub(crate) experimental_decorators: Option, /// `None` = unset (keeps native [[Define]] class-field semantics). pub use_define_for_class_fields: Option, pub(crate) has_compiler_options: bool, @@ -183,8 +185,8 @@ impl Default for TSConfigJSON { jsx: options::jsx::Pragma::default(), jsx_flags: JsxFieldSet::empty(), preserve_imports_not_used_as_values: Some(false), - emit_decorator_metadata: false, - experimental_decorators: false, + emit_decorator_metadata: None, + experimental_decorators: None, use_define_for_class_fields: None, has_compiler_options: false, } @@ -211,6 +213,37 @@ bun_core::comptime_string_map! { // `destroy(TSConfigJSON)` lines to assert the extends-chain merge frees intermediates. bun_core::declare_scope!(alloc, hidden); +/// `current` with the fields of `jsx` that `flags` names written over it. +fn merge_jsx_fields( + jsx: &options::jsx::Pragma, + flags: JsxFieldSet, + current: options::jsx::Pragma, +) -> options::jsx::Pragma { + let mut out = current; + + if flags.contains(JsxField::Factory) { + out.factory = jsx.factory.clone(); + } + + if flags.contains(JsxField::Fragment) { + out.fragment = jsx.fragment.clone(); + } + + if flags.contains(JsxField::ImportSource) { + out.import_source = jsx.import_source.clone(); + } + + if flags.contains(JsxField::Runtime) { + out.runtime = jsx.runtime; + } + + if flags.contains(JsxField::Development) { + out.development = jsx.development; + } + + out +} + impl TSConfigJSON { #[inline] pub(crate) fn new(v: Self) -> Box { @@ -230,6 +263,54 @@ impl TSConfigJSON { drop(boxed); } + pub fn emit_decorator_metadata(&self) -> bool { + self.emit_decorator_metadata.unwrap_or(false) + } + + pub fn experimental_decorators(&self) -> bool { + self.experimental_decorators.unwrap_or(false) + } + + /// Takes every option this config does not set from `base`, the config it extends. + pub(crate) fn inherit(&mut self, mut base: Box) { + let Self { + abs_path: _, + base_url, + base_url_for_paths, + extends: _, + paths, + jsx, + jsx_flags, + preserve_imports_not_used_as_values, + emit_decorator_metadata, + experimental_decorators, + use_define_for_class_fields, + has_compiler_options, + } = &mut *base; + + if self.base_url.is_empty() { + self.base_url = core::mem::take(base_url); + } + // `base_url_for_paths` is set when the `paths` key is present, even as `{}`, which clears them. + if self.base_url_for_paths.is_empty() { + self.paths = core::mem::take(paths); + self.base_url_for_paths = core::mem::take(base_url_for_paths); + } + self.jsx = merge_jsx_fields(&self.jsx, self.jsx_flags, core::mem::take(jsx)); + self.jsx_flags.insert_all(*jsx_flags); + self.preserve_imports_not_used_as_values = + (self.preserve_imports_not_used_as_values).or(*preserve_imports_not_used_as_values); + self.emit_decorator_metadata = self.emit_decorator_metadata.or(*emit_decorator_metadata); + self.experimental_decorators = self.experimental_decorators.or(*experimental_decorators); + self.use_define_for_class_fields = self + .use_define_for_class_fields + .or(*use_define_for_class_fields); + self.has_compiler_options |= *has_compiler_options; + + // `destroy` logs what tsconfig-extends-leak.test.ts counts. + Self::destroy(base); + } + pub(crate) fn has_base_url(&self) -> bool { !self.base_url.is_empty() } @@ -276,30 +357,9 @@ impl TSConfigJSON { longest_match } + /// `current` with the JSX options that this config sets written over it. pub fn merge_jsx(&self, current: options::jsx::Pragma) -> options::jsx::Pragma { - let mut out = current; - - if self.jsx_flags.contains(JsxField::Factory) { - out.factory = self.jsx.factory.clone(); - } - - if self.jsx_flags.contains(JsxField::Fragment) { - out.fragment = self.jsx.fragment.clone(); - } - - if self.jsx_flags.contains(JsxField::ImportSource) { - out.import_source = self.jsx.import_source.clone(); - } - - if self.jsx_flags.contains(JsxField::Runtime) { - out.runtime = self.jsx.runtime; - } - - if self.jsx_flags.contains(JsxField::Development) { - out.development = self.jsx.development; - } - - out + merge_jsx_fields(&self.jsx, self.jsx_flags, current) } /// Support ${configDir}, but avoid allocating when possible. @@ -487,12 +547,12 @@ impl TSConfigJSON { // Parse "emitDecoratorMetadata" if let Some(&bun_ast::E::JsonValue::Boolean(val)) = emit_decorator_metadata_v { - result.emit_decorator_metadata = val; + result.emit_decorator_metadata = Some(val); } // Parse "experimentalDecorators" if let Some(&bun_ast::E::JsonValue::Boolean(val)) = experimental_decorators_v { - result.experimental_decorators = val; + result.experimental_decorators = Some(val); } // Parse "useDefineForClassFields" @@ -851,3 +911,93 @@ impl TSConfigJSON { // `Box` drop handles cleanup: PathsMap has Drop, and Box // frees the allocation. No explicit Drop needed. } + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use super::*; + + fn config(write: impl FnOnce(&mut TSConfigJSON)) -> Box { + let mut config = TSConfigJSON::default(); + write(&mut config); + Box::new(config) + } + + fn paths(key: &[u8], value: &[u8]) -> PathsMap { + let mut paths = PathsMap::default(); + paths + .put(Box::from(key), vec![Box::from(value)]) + .expect("OOM"); + paths + } + + #[test] + fn extending_config_keeps_the_options_it_sets() { + let mut merged = config(|child| { + child.abs_path = Box::from(&b"/p/tsconfig.json"[..]); + child.emit_decorator_metadata = Some(false); + child.use_define_for_class_fields = Some(false); + child.jsx.import_source.production = Cow::Borrowed(b"preact"); + child.jsx_flags = JsxField::ImportSource.into(); + child.paths = paths(b"@leaf/*", b"./leaf/*"); + child.base_url_for_paths = Box::from(&b"/p/leaf"[..]); + child.has_compiler_options = true; + }); + merged.inherit(config(|base| { + base.abs_path = Box::from(&b"/p/base.json"[..]); + base.base_url = Box::from(&b"/p"[..]); + base.emit_decorator_metadata = Some(true); + base.experimental_decorators = Some(true); + base.use_define_for_class_fields = Some(true); + base.jsx.runtime = options::jsx::Runtime::Classic; + base.jsx_flags = JsxField::Runtime.into(); + base.paths = paths(b"@base/*", b"./base/*"); + base.base_url_for_paths = Box::from(&b"/p"[..]); + })); + assert_eq!(&*merged.abs_path, b"/p/tsconfig.json"); + assert_eq!(&*merged.base_url, b"/p"); + assert_eq!(merged.emit_decorator_metadata, Some(false)); + assert_eq!(merged.experimental_decorators, Some(true)); + assert_eq!(merged.use_define_for_class_fields, Some(false)); + assert_eq!(merged.jsx.runtime, options::jsx::Runtime::Classic); + assert_eq!(&*merged.jsx.import_source.production, b"preact"); + assert_eq!(merged.jsx_flags, JsxField::Runtime | JsxField::ImportSource); + assert_eq!(merged.paths.keys(), [Box::from(&b"@leaf/*"[..])]); + assert_eq!(&*merged.base_url_for_paths, b"/p/leaf"); + assert!(merged.has_compiler_options); + } + + #[test] + fn options_the_extending_config_leaves_unset_come_from_the_nearest_base_that_sets_them() { + let mut middle = config(|middle| { + middle.emit_decorator_metadata = Some(false); + }); + middle.inherit(config(|base| { + base.emit_decorator_metadata = Some(true); + base.experimental_decorators = Some(true); + base.paths = paths(b"@base/*", b"./base/*"); + base.base_url_for_paths = Box::from(&b"/p"[..]); + base.has_compiler_options = true; + })); + let mut merged = config(|_| {}); + merged.inherit(middle); + assert_eq!(merged.emit_decorator_metadata, Some(false)); + assert_eq!(merged.experimental_decorators, Some(true)); + assert_eq!(merged.paths.keys(), [Box::from(&b"@base/*"[..])]); + assert!(merged.has_compiler_options); + } + + #[test] + fn empty_paths_in_the_extending_config_clear_the_inherited_ones() { + let mut merged = config(|child| { + child.base_url_for_paths = Box::from(&b"/p/child"[..]); + }); + merged.inherit(config(|base| { + base.paths = paths(b"@base/*", b"./base/*"); + base.base_url_for_paths = Box::from(&b"/p"[..]); + })); + assert!(merged.paths.is_empty()); + assert_eq!(&*merged.base_url_for_paths, b"/p/child"); + } +} diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 109ac3df178c..524f223a4d5c 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -725,8 +725,8 @@ impl TransformTask { path: source.path, virtual_source: Some(source), replace_exports: self.replace_exports.entries.clone().expect("OOM"), - experimental_decorators: tsconfig.is_some_and(|ts| ts.experimental_decorators), - emit_decorator_metadata: tsconfig.is_some_and(|ts| ts.emit_decorator_metadata), + experimental_decorators: tsconfig.is_some_and(|ts| ts.experimental_decorators()), + emit_decorator_metadata: tsconfig.is_some_and(|ts| ts.emit_decorator_metadata()), use_define_for_class_fields: tsconfig .and_then(|ts| ts.use_define_for_class_fields) .unwrap_or(true), @@ -1142,11 +1142,11 @@ impl JSTranspiler { experimental_decorators: config .tsconfig .as_deref() - .is_some_and(|ts| ts.experimental_decorators), + .is_some_and(|ts| ts.experimental_decorators()), emit_decorator_metadata: config .tsconfig .as_deref() - .is_some_and(|ts| ts.emit_decorator_metadata), + .is_some_and(|ts| ts.emit_decorator_metadata()), use_define_for_class_fields: config .tsconfig .as_deref() diff --git a/src/sema/resolve/code/config.rs b/src/sema/resolve/code/config.rs index bc37505e551e..c3509bf688f3 100644 --- a/src/sema/resolve/code/config.rs +++ b/src/sema/resolve/code/config.rs @@ -213,8 +213,8 @@ struct ParsedConfig { paths: Option, paths_directory: Vec, jsx: JsxFields, - emits_decorator_metadata: bool, - experimental_decorators: bool, + emits_decorator_metadata: Option, + experimental_decorators: Option, use_define_for_class_fields: Option, has_compiler_options: bool, } @@ -244,25 +244,38 @@ impl ParsedConfig { paths_directory: base_url.clone().unwrap_or_else(|| config_dir.to_vec()), base_url, jsx: JsxFields::parse(options), - emits_decorator_metadata: bool_entry(options, b"emitDecoratorMetadata") == Some(true), - experimental_decorators: bool_entry(options, b"experimentalDecorators") == Some(true), + emits_decorator_metadata: bool_entry(options, b"emitDecoratorMetadata"), + experimental_decorators: bool_entry(options, b"experimentalDecorators"), use_define_for_class_fields: bool_entry(options, b"useDefineForClassFields"), has_compiler_options: true, } } fn inherit(&mut self, parent: ParsedConfig) { - self.extends = parent.extends; - self.base_url = self.base_url.take().or(parent.base_url); + let ParsedConfig { + extends, + base_url, + paths, + paths_directory, + jsx, + emits_decorator_metadata, + experimental_decorators, + use_define_for_class_fields, + has_compiler_options, + } = parent; + self.extends = extends; + self.base_url = self.base_url.take().or(base_url); if self.paths.is_none() { - self.paths = parent.paths; - self.paths_directory = parent.paths_directory; + self.paths = paths; + self.paths_directory = paths_directory; } - self.jsx.inherit(parent.jsx); - self.emits_decorator_metadata |= parent.emits_decorator_metadata; - self.experimental_decorators = parent.experimental_decorators; - let use_define = parent.use_define_for_class_fields; - self.use_define_for_class_fields = self.use_define_for_class_fields.or(use_define); + self.jsx.inherit(jsx); + self.emits_decorator_metadata = self.emits_decorator_metadata.or(emits_decorator_metadata); + self.experimental_decorators = self.experimental_decorators.or(experimental_decorators); + self.use_define_for_class_fields = self + .use_define_for_class_fields + .or(use_define_for_class_fields); + self.has_compiler_options |= has_compiler_options; } } @@ -341,8 +354,8 @@ impl<'h> Resolver<'h> { is_development, ..stated.jsx.apply_to(&JsxSettings::default()) }, - experimental_decorators: stated.experimental_decorators, - emits_decorator_metadata: stated.emits_decorator_metadata, + experimental_decorators: stated.experimental_decorators.unwrap_or(false), + emits_decorator_metadata: stated.emits_decorator_metadata.unwrap_or(false), use_define_for_class_fields: stated.use_define_for_class_fields, bun_run_assigns_class_fields: self.bun_run_assigns_class_fields(), }); @@ -434,8 +447,8 @@ impl<'h> Resolver<'h> { is_development: self.is_jsx_development(), ..config.jsx.apply_to(&self.code_options().jsx) }, - experimental_decorators: config.experimental_decorators, - emits_decorator_metadata: config.emits_decorator_metadata, + experimental_decorators: config.experimental_decorators.unwrap_or(false), + emits_decorator_metadata: config.emits_decorator_metadata.unwrap_or(false), use_define_for_class_fields: config.use_define_for_class_fields, bun_run_assigns_class_fields: self.bun_run_assigns_class_fields(), }) diff --git a/src/sema/resolve/code/config/tests.rs b/src/sema/resolve/code/config/tests.rs index 453d8241a725..67e7b2af1b5e 100644 --- a/src/sema/resolve/code/config/tests.rs +++ b/src/sema/resolve/code/config/tests.rs @@ -243,49 +243,45 @@ fn extends_merges_settings_by_option() { let host = MemoryHost::new(&[("/lib/tsconfig.json", child), ("/lib/base.json", base)]); let merged = settings(&host, "/lib/a.tsx"); assert!(merged.emits_decorator_metadata); - assert!(!merged.experimental_decorators); + assert!(merged.experimental_decorators); assert_eq!(merged.use_define_for_class_fields, Some(false)); assert_eq!(render_jsx(&merged.jsx), "Classic dev h Fragment preact"); } #[test] #[cfg_attr(miri, ignore)] -fn last_extended_file_decides_experimental_decorators() { - let cases: [(&[Option], bool); 9] = [ - (&[Some(true)], true), - (&[None], false), - (&[Some(true), None], false), - (&[Some(true), Some(false)], false), - (&[None, Some(true)], true), - (&[Some(false), Some(true)], true), - (&[Some(true), Some(true), None], false), - (&[None, Some(true), Some(false)], false), - (&[None, None, Some(true)], true), - ]; - for (written, expected) in cases { - let merged = extends_chain_settings("experimentalDecorators", written); - assert_eq!(merged.experimental_decorators, expected, "{written:?}"); - assert!(!merged.emits_decorator_metadata, "{written:?}"); - } -} - -#[test] -#[cfg_attr(miri, ignore)] -fn any_extended_file_sets_decorator_metadata() { - let cases: [(&[Option], bool); 8] = [ +fn nearest_extended_file_decides_decorator_options() { + let cases: [(&[Option], bool); 10] = [ (&[Some(true)], true), (&[None], false), (&[Some(true), None], true), (&[Some(true), Some(false)], true), - (&[Some(false), Some(true)], true), - (&[None, Some(true), None], true), + (&[Some(false), Some(true)], false), + (&[None, Some(true)], true), + (&[Some(true), Some(true), None], true), + (&[None, Some(true), Some(false)], true), + (&[None, Some(false), Some(true)], false), (&[None, None, Some(true)], true), - (&[Some(false), None, Some(false)], false), ]; + let read = |merged: &TranspileSettings| { + [ + merged.experimental_decorators, + merged.emits_decorator_metadata, + ] + }; for (written, expected) in cases { + let merged = extends_chain_settings("experimentalDecorators", written); + assert_eq!( + read(&merged), + [expected, false], + "experimentalDecorators {written:?}" + ); let merged = extends_chain_settings("emitDecoratorMetadata", written); - assert_eq!(merged.emits_decorator_metadata, expected, "{written:?}"); - assert!(!merged.experimental_decorators, "{written:?}"); + assert_eq!( + read(&merged), + [false, expected], + "emitDecoratorMetadata {written:?}" + ); } } diff --git a/test/js/bun/resolve/tsconfig-extends-leak.test.ts b/test/js/bun/resolve/tsconfig-extends-leak.test.ts index 65b8ef8e72ac..e558045c2ac8 100644 --- a/test/js/bun/resolve/tsconfig-extends-leak.test.ts +++ b/test/js/bun/resolve/tsconfig-extends-leak.test.ts @@ -12,9 +12,10 @@ // the debug-build `BUN_DEBUG_alloc=1` instrumentation which logs every // bun.new()/bun.destroy() call, and count TSConfigJSON lifetimes directly. -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isDebug, tempDir } from "harness"; import path from "path"; +import { unsupported as aotUnsupported } from "../../../aot/harness/compiler"; // The allocation log is only emitted in builds with Environment.allow_assert // (debug/ReleaseSafe). Release CI lanes skip this; the gate runs under bun bd @@ -122,3 +123,140 @@ test("tsconfig 'extends' merge still works after freeing intermediates", async ( expect(stdout.trim()).toBe("leaf"); expect(exitCode).toBe(0); }); + +// A decorator option the extending file sets has to survive the merge, and an +// explicit `false` in the extending file has to clear the base's `true`, as in +// tsc. A legacy field decorator is called with 3 arguments, a TC39 one with 2. +describe("tsconfig 'extends' decorator options", () => { + const legacyFieldDecorator = ` + function d(..._args: any[]) { console.log("arguments:", arguments.length); } + class A { @d x = 1; } + new A(); + `; + + async function run(files: Record, flags: string[] = []) { + using dir = tempDir("tsconfig-extends-decorators", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flags, "a.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + } + + test.concurrent.each([ + ["the extending file sets it and the base sets nothing", { experimentalDecorators: true }, {}, "arguments: 3"], + [ + "the extending file sets it and the base sets another option", + { experimentalDecorators: true }, + { strict: true }, + "arguments: 3", + ], + ["the base sets it and the extending file sets nothing", {}, { experimentalDecorators: true }, "arguments: 3"], + ["both set it", { experimentalDecorators: true }, { experimentalDecorators: true }, "arguments: 3"], + [ + "the extending file clears it", + { experimentalDecorators: false }, + { experimentalDecorators: true }, + "arguments: 2", + ], + ["neither sets it", {}, {}, "arguments: 2"], + ])("experimentalDecorators when %s", async (_, child, base, expected) => { + const { stdout, stderr, exitCode } = await run({ + "a.ts": legacyFieldDecorator, + "base.json": JSON.stringify({ compilerOptions: base }), + "tsconfig.json": JSON.stringify({ extends: "./base.json", compilerOptions: child }), + }); + expect(stdout).toBe(expected); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test.concurrent("experimentalDecorators set in the extending file survives a chain of two bases", async () => { + const { stdout, stderr, exitCode } = await run({ + "a.ts": legacyFieldDecorator, + "root.json": JSON.stringify({ compilerOptions: { strict: true } }), + "base.json": JSON.stringify({ extends: "./root.json", compilerOptions: { paths: {} } }), + "tsconfig.json": JSON.stringify({ extends: "./base.json", compilerOptions: { experimentalDecorators: true } }), + }); + expect(stdout).toBe("arguments: 3"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + // --tsconfig-override names a file that extends the project's tsconfig.json (#30477). + test.concurrent("experimentalDecorators set in the file --tsconfig-override names survives its extends", async () => { + const { stdout, stderr, exitCode } = await run( + { + "a.ts": legacyFieldDecorator, + "tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "tsconfig.bun.json": JSON.stringify({ + extends: "./tsconfig.json", + compilerOptions: { experimentalDecorators: true }, + }), + }, + ["--tsconfig-override", "./tsconfig.bun.json"], + ); + expect(stdout).toBe("arguments: 3"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + // The compiled program of --debug-aot takes its decorator options through another reader of tsconfig.json. + test.concurrent.skipIf(!!aotUnsupported)( + "experimentalDecorators set in the extending file survives for a compiled program", + async () => { + const { stdout, stderr, exitCode } = await run( + { + "a.ts": legacyFieldDecorator, + "base.json": JSON.stringify({ compilerOptions: { strict: true } }), + "tsconfig.json": JSON.stringify({ + extends: "./base.json", + compilerOptions: { experimentalDecorators: true }, + }), + }, + ["--debug-aot"], + ); + expect(stdout).toBe("arguments: 3"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }, + // The compile of a debug build under ASAN takes seconds. + 60_000, + ); + + // With emitDecoratorMetadata a decorated field gets a `design:type` entry. + const metadataProbe = ` + function d(..._args: any[]) {} + class A { @d x: number = 1; } + console.log("metadata:", Reflect.getMetadata?.("design:type", A.prototype, "x")?.name ?? "none"); + `; + const reflectShim = ` + const store = new Map(); + (Reflect as any).metadata = (key: string, value: any) => (target: any, name: string) => store.set(key + name, value); + (Reflect as any).getMetadata = (key: string, _target: any, name: string) => store.get(key + name); + `; + + test.concurrent.each([ + ["the extending file sets it", { emitDecoratorMetadata: true }, {}, "metadata: Number"], + ["the base sets it", {}, { emitDecoratorMetadata: true }, "metadata: Number"], + [ + "the extending file clears it", + { emitDecoratorMetadata: false }, + { emitDecoratorMetadata: true }, + "metadata: none", + ], + ])("emitDecoratorMetadata when %s", async (_, child, base, expected) => { + const { stdout, stderr, exitCode } = await run({ + "a.ts": reflectShim + metadataProbe, + "base.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, ...base } }), + "tsconfig.json": JSON.stringify({ extends: "./base.json", compilerOptions: child }), + }); + expect(stdout).toBe(expected); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +});