diff --git a/Cargo.lock b/Cargo.lock index fc2558f931..a71492aa2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -220,7 +220,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -590,6 +590,16 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -849,6 +859,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecheck" @@ -977,6 +990,15 @@ dependencies = [ "displaydoc", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1222,6 +1244,19 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1689,6 +1724,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1883,7 +1928,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2188,7 +2233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2740,7 +2785,7 @@ dependencies = [ "gobject-sys 0.22.6", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3797,6 +3842,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "image" version = "0.25.10" @@ -3979,7 +4030,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4948,7 +4999,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5036,6 +5087,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -5318,6 +5379,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -5379,6 +5446,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "par-core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96cbd21255b7fb29a5d51ef38a779b517a91abd59e2756c039583f43ef4c90f" +dependencies = [ + "once_cell", +] + [[package]] name = "parking" version = "2.2.1" @@ -6084,6 +6160,22 @@ dependencies = [ "perry-runtime", ] +[[package]] +name = "perry-ext-typescript" +version = "0.5.1516" +dependencies = [ + "perry-ffi", + "serde", + "serde_json", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_parser 29.0.2", + "swc_ecma_transforms_base", + "swc_ecma_transforms_react", + "swc_ecma_transforms_typescript", +] + [[package]] name = "perry-ext-undici" version = "0.5.1516" @@ -6169,7 +6261,7 @@ dependencies = [ "perry-diagnostics", "swc_common", "swc_ecma_ast", - "swc_ecma_parser", + "swc_ecma_parser 32.0.0", "thiserror 1.0.69", ] @@ -7636,7 +7728,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7722,6 +7814,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "salsa20" version = "0.10.2" @@ -8270,7 +8368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8547,7 +8645,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8635,6 +8733,18 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swc_allocator" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7eefd2c8b228a8c73056482b2ae4b3a1071fbe07638e3b55ceca8570cc48bb" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.14.5", + "rustc-hash 2.1.2", +] + [[package]] name = "swc_atoms" version = "9.0.3" @@ -8671,6 +8781,32 @@ dependencies = [ "url", ] +[[package]] +name = "swc_config" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e90b52ee734ded867104612218101722ad87ff4cf74fe30383bd244a533f97" +dependencies = [ + "anyhow", + "bytes-str", + "indexmap", + "serde", + "serde_json", + "swc_config_macro", +] + +[[package]] +name = "swc_config_macro" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b416e8ce6de17dc5ea496e10c7012b35bbc0e3fef38d2e065eed936490db0b3" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "swc_ecma_ast" version = "19.0.0" @@ -8690,6 +8826,62 @@ dependencies = [ "unicode-id-start", ] +[[package]] +name = "swc_ecma_codegen" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c77d9d21345ca986ae3b5ff1a4fa3607b15b07ed397506e6dba32e867cf40fd" +dependencies = [ + "ascii", + "compact_str", + "memchr", + "num-bigint", + "once_cell", + "regex", + "rustc-hash 2.1.2", + "ryu-js", + "serde", + "swc_allocator", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen_macros", + "swc_sourcemap", + "tracing", +] + +[[package]] +name = "swc_ecma_codegen_macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" +dependencies = [ + "proc-macro2", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "swc_ecma_parser" +version = "29.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e63984b544fe1d8f66e9ce616e57429bb878572fcf1504851ef9d9f4f5260e2b" +dependencies = [ + "bitflags 2.12.1", + "either", + "num-bigint", + "phf 0.11.3", + "rustc-hash 2.1.2", + "seq-macro", + "serde", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", +] + [[package]] name = "swc_ecma_parser" version = "32.0.0" @@ -8711,6 +8903,104 @@ dependencies = [ "tracing", ] +[[package]] +name = "swc_ecma_transforms_base" +version = "32.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "499486ed875ba49af2f36d0d809f61ce6e42943a3aa1d97f592d96f10fe734cc" +dependencies = [ + "better_scoped_tls", + "indexmap", + "once_cell", + "par-core", + "phf 0.11.3", + "rustc-hash 2.1.2", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser 29.0.2", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_transforms_react" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d917bf232b72a3fee91b6eb281930264bab6ae1c20b311f5d27f9c918bb8ea" +dependencies = [ + "base64 0.22.1", + "bytes-str", + "indexmap", + "once_cell", + "rustc-hash 2.1.2", + "serde", + "sha1 0.10.6", + "string_enum", + "swc_atoms", + "swc_common", + "swc_config", + "swc_ecma_ast", + "swc_ecma_parser 29.0.2", + "swc_ecma_transforms_base", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_transforms_typescript" +version = "35.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a8624ca5f7d96659ac43eb9d85b4afd8af00e4c17eba1685b557b1ad6285a3" +dependencies = [ + "bytes-str", + "rustc-hash 2.1.2", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_transforms_base", + "swc_ecma_transforms_react", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_utils" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd5ee449d21110a271e73d0a9f7640a8854a62cb0e2cb0c9db3445383598e21" +dependencies = [ + "indexmap", + "num_cpus", + "once_cell", + "par-core", + "rustc-hash 2.1.2", + "ryu-js", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_visit" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69d63f7f704a2ec937edef90a3eba1f64602eceb60c8deb260c01131f680e8b" +dependencies = [ + "new_debug_unreachable", + "num-bigint", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_visit", + "tracing", +] + [[package]] name = "swc_eq_ignore_macros" version = "1.0.1" @@ -8733,6 +9023,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "swc_sourcemap" +version = "9.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de08ef00f816acdd1a58ee8a81c0e1a59eefef2093aefe5611f256fa6b64c4d7" +dependencies = [ + "base64-simd", + "bitvec", + "bytes-str", + "data-encoding", + "debugid", + "if_chain", + "rustc-hash 2.1.2", + "serde", + "serde_json", + "unicode-id-start", + "url", +] + [[package]] name = "swc_visit" version = "2.0.1" @@ -8886,7 +9195,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9483,7 +9792,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9705,6 +10014,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -10080,7 +10395,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7ee9765882..2536a60bdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ "crates/perry-ext-ads", "crates/perry-ext-node-forge", "crates/perry-ext-parcel-watcher", + "crates/perry-ext-typescript", "crates/perry-wasm-host", "crates/perry-container-compose", "crates/perry-container-e2e", @@ -344,6 +345,10 @@ not_unsafe_ptr_arg_deref = "allow" swc_ecma_parser = "32.0" swc_ecma_ast = "19.0" swc_common = "18.0" +swc_ecma_codegen = "21.0" +swc_ecma_transforms_base = "32.0" +swc_ecma_transforms_react = "35.0" +swc_ecma_transforms_typescript = "35.0" # Runtime dependencies # bdwgc for garbage collection (Boehm GC) @@ -491,6 +496,7 @@ perry-ext-pdf = { path = "crates/perry-ext-pdf" } perry-ext-ads = { path = "crates/perry-ext-ads" } perry-ext-node-forge = { path = "crates/perry-ext-node-forge" } perry-ext-parcel-watcher = { path = "crates/perry-ext-parcel-watcher" } +perry-ext-typescript = { path = "crates/perry-ext-typescript" } perry-stdlib = { path = "crates/perry-stdlib" } perry-diagnostics = { path = "crates/perry-diagnostics" } perry-ui-model = { path = "crates/perry-ui-model" } diff --git a/changelog.d/8535-typescript-transpile-module.md b/changelog.d/8535-typescript-transpile-module.md new file mode 100644 index 0000000000..8108536369 --- /dev/null +++ b/changelog.d/8535-typescript-transpile-module.md @@ -0,0 +1,6 @@ +### Added + +- OpenCode Code Mode can use its audited `typescript` runtime surface through + a native SWC-backed `transpileModule` provider, including TS/TSX lowering, + compiler enum constants, and diagnostic formatting, without embedding the + upstream TypeScript compiler or a JavaScript engine. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 88d7e38a07..a6f47efd3a 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -199,6 +199,9 @@ pub const NATIVE_MODULES: &[&str] = &[ "@parcel/watcher-linux-arm64-musl", "@parcel/watcher-win32-x64", "@parcel/watcher-win32-arm64", + // #8511: the runtime `transpileModule` subset used by OpenCode Code Mode. + // The upstream compiler is deliberately not compiled into the app. + "typescript", ]; /// Node built-in submodules that Perry routes through the diff --git a/crates/perry-api-manifest/src/entries/part_2.rs b/crates/perry-api-manifest/src/entries/part_2.rs index 51912b369c..4b4c4b5f67 100644 --- a/crates/perry-api-manifest/src/entries/part_2.rs +++ b/crates/perry-api-manifest/src/entries/part_2.rs @@ -8,6 +8,14 @@ use super::*; pub(crate) const API_MANIFEST_PART_2: &[ApiEntry] = &[ + // #8511: deliberately narrow TypeScript runtime compatibility. These are + // the only value exports audited in OpenCode Code Mode; strict manifest + // gating makes every other TypeScript compiler API fail explicitly. + method("typescript", "transpileModule", false, None), + method("typescript", "flattenDiagnosticMessageText", false, None), + property("typescript", "ScriptTarget"), + property("typescript", "ModuleKind"), + property("typescript", "DiagnosticCategory"), method_sig( "@parcel/watcher", "subscribe", diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index 8646e3a45c..87e5e4173b 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -624,6 +624,8 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[ // node-forge PKI subset — RSA keygen, X.509 build/sign, PEM // (perry-ext-node-forge). Hyphenated package → underscored prefix. ("js_node_forge_", "node-forge"), + // Native runtime TypeScript transpilation subset (#8511). + ("js_typescript_", "typescript"), ]; /// Process-wide collector of provider keys observed during codegen. diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index 4016c04f28..3115da4e5a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -36,6 +36,7 @@ mod parcel_watcher; mod thread_lodash; mod tls_events; mod tui; +mod typescript; mod undici; mod utils_crypto; mod yoga; @@ -178,6 +179,7 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n v.extend_from_slice(native_profile::NATIVE_PROFILE_ROWS); v.extend_from_slice(parcel_watcher::PARCEL_WATCHER_ROWS); v.extend_from_slice(tui::TUI_ROWS); + v.extend_from_slice(typescript::TYPESCRIPT_ROWS); v.extend_from_slice(yoga::YOGA_ROWS); v.extend_from_slice(extras::EXTRAS_ROWS); v.extend_from_slice(http_client::HTTP_CLIENT_ROWS); diff --git a/crates/perry-codegen/src/lower_call/native_table/typescript.rs b/crates/perry-codegen/src/lower_call/native_table/typescript.rs new file mode 100644 index 0000000000..c5be253e81 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/native_table/typescript.rs @@ -0,0 +1,24 @@ +use super::*; + +/// Native TypeScript runtime-transpilation subset used by OpenCode Code Mode +/// (#8511). The compiler enums are folded by HIR and therefore need no rows. +pub(super) const TYPESCRIPT_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "typescript", + has_receiver: false, + method: "transpileModule", + class_filter: None, + runtime: "js_typescript_transpile_module", + args: &[NA_STR, NA_F64], + ret: NR_OBJ_FROM_JSON_STR, + }, + NativeModSig { + module: "typescript", + has_receiver: false, + method: "flattenDiagnosticMessageText", + class_filter: None, + runtime: "js_typescript_flatten_diagnostic_message_text", + args: &[NA_F64, NA_STR, NA_F64], + ret: NR_STR, + }, +]; diff --git a/crates/perry-ext-typescript/Cargo.toml b/crates/perry-ext-typescript/Cargo.toml new file mode 100644 index 0000000000..a23bcb2eff --- /dev/null +++ b/crates/perry-ext-typescript/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "perry-ext-typescript" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native TypeScript transpileModule compatibility subset for Perry" + +[lints] +workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +perry-ffi.workspace = true +serde.workspace = true +serde_json.workspace = true +swc_common.workspace = true +swc_ecma_ast.workspace = true +swc_ecma_codegen.workspace = true +# Keep this aligned with the parser generation used by the SWC transform +# crates below so native applications do not link two parser copies. +swc_ecma_parser = "29.0" +swc_ecma_transforms_base.workspace = true +swc_ecma_transforms_react.workspace = true +swc_ecma_transforms_typescript.workspace = true diff --git a/crates/perry-ext-typescript/src/lib.rs b/crates/perry-ext-typescript/src/lib.rs new file mode 100644 index 0000000000..eeb47e725a --- /dev/null +++ b/crates/perry-ext-typescript/src/lib.rs @@ -0,0 +1,485 @@ +//! Native compatibility subset for TypeScript's runtime transpilation API. +//! +//! OpenCode Code Mode uses TypeScript only to erase TypeScript syntax from a +//! runtime string before feeding the emitted JavaScript to Acorn. Shipping the +//! upstream compiler for that narrow path is disproportionate, so this crate +//! exposes the audited `transpileModule` and `flattenDiagnosticMessageText` +//! surface using SWC, which Perry already uses for source parsing. + +use perry_ffi::{alloc_string, json_stringify, read_string, JsString, JsValue, StringHeader}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use swc_common::{ + comments::SingleThreadedComments, sync::Lrc, FileName, Globals, Mark, SourceMap, Spanned, + GLOBALS, +}; +use swc_ecma_ast::{EsVersion, Pass, Program}; +use swc_ecma_codegen::{text_writer::JsWriter, Config as CodegenConfig, Emitter}; +use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax}; +use swc_ecma_transforms_base::resolver; +use swc_ecma_transforms_react::{react, Options as ReactOptions, Runtime as ReactRuntime}; +use swc_ecma_transforms_typescript::{tsx, typescript, Config as TypeScriptConfig, TsxConfig}; + +const DIAGNOSTIC_ERROR: u8 = 1; +const SCRIPT_TARGET_ES_NEXT: i32 = 99; +const MODULE_KIND_ES_NEXT: i32 = 99; +const MODULE_KIND_PRESERVE: i32 = 200; +const JSX_PRESERVE: i32 = 1; +const JSX_REACT: i32 = 2; + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TranspileOptions { + #[serde(default)] + compiler_options: CompilerOptions, + #[serde(default = "default_file_name")] + file_name: String, + #[serde(default)] + report_diagnostics: bool, + #[serde(flatten)] + extra: serde_json::Map, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CompilerOptions { + target: Option, + module: Option, + jsx: Option, + jsx_factory: Option, + jsx_fragment_factory: Option, + #[serde(flatten)] + extra: serde_json::Map, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct TranspileOutput { + output_text: String, + #[serde(skip_serializing_if = "Option::is_none")] + diagnostics: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Diagnostic { + category: u8, + code: u32, + message_text: String, + #[serde(skip_serializing_if = "Option::is_none")] + start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + length: Option, +} + +impl Diagnostic { + fn error(code: u32, message_text: impl Into) -> Self { + Self { + category: DIAGNOSTIC_ERROR, + code, + message_text: message_text.into(), + start: None, + length: None, + } + } + + fn parse(code: u32, message_text: impl Into, lo: u32, hi: u32) -> Self { + // SWC byte positions are one-based. TypeScript diagnostics expose a + // zero-based offset and a length. + let start = lo.saturating_sub(1); + Self { + category: DIAGNOSTIC_ERROR, + code, + message_text: message_text.into(), + start: Some(start), + length: Some(hi.saturating_sub(lo).max(1)), + } + } +} + +fn default_file_name() -> String { + "module.ts".to_string() +} + +fn options_from_json(json: Option<&str>) -> Result { + match json { + None | Some("") | Some("null") => Ok(TranspileOptions { + file_name: default_file_name(), + ..Default::default() + }), + Some(json) => serde_json::from_str(json).map_err(|error| { + Diagnostic::error(90001, format!("Invalid transpile options: {error}")) + }), + } +} + +fn validate_options(options: &TranspileOptions) -> Vec { + let mut diagnostics = Vec::new(); + + if !options.extra.is_empty() { + let keys = options.extra.keys().cloned().collect::>().join(", "); + diagnostics.push(Diagnostic::error( + 90002, + format!("Unsupported TypeScript transpile option(s): {keys}"), + )); + } + if !options.compiler_options.extra.is_empty() { + let keys = options + .compiler_options + .extra + .keys() + .cloned() + .collect::>() + .join(", "); + diagnostics.push(Diagnostic::error( + 90003, + format!("Unsupported TypeScript compiler option(s): {keys}"), + )); + } + if let Some(target) = options.compiler_options.target { + if target != SCRIPT_TARGET_ES_NEXT { + diagnostics.push(Diagnostic::error( + 90004, + format!( + "Unsupported ScriptTarget value {target}; Perry's native transpileModule subset supports ESNext" + ), + )); + } + } + if let Some(module) = options.compiler_options.module { + if !matches!(module, MODULE_KIND_ES_NEXT | MODULE_KIND_PRESERVE) { + diagnostics.push(Diagnostic::error( + 90005, + format!( + "Unsupported ModuleKind value {module}; Perry's native transpileModule subset supports ESNext and Preserve" + ), + )); + } + } + if let Some(jsx) = options.compiler_options.jsx { + if !matches!(jsx, JSX_PRESERVE | JSX_REACT) { + diagnostics.push(Diagnostic::error( + 90006, + format!( + "Unsupported JsxEmit value {jsx}; Perry's native transpileModule subset supports Preserve and React" + ), + )); + } + } + + diagnostics +} + +fn is_tsx(file_name: &str) -> bool { + let bare = file_name.split(['?', '#']).next().unwrap_or(file_name); + bare.to_ascii_lowercase().ends_with(".tsx") +} + +fn transpile(source: &str, options_json: Option<&str>) -> TranspileOutput { + let options = match options_from_json(options_json) { + Ok(options) => options, + Err(error) => { + return TranspileOutput { + output_text: String::new(), + diagnostics: Some(vec![error]), + }; + } + }; + let mut diagnostics = validate_options(&options); + if !diagnostics.is_empty() { + return TranspileOutput { + output_text: String::new(), + // Unsupported subset options are Perry compatibility errors, not + // TypeScript syntax diagnostics. Keep them visible even when the + // caller did not request ordinary parser diagnostics. + diagnostics: Some(diagnostics), + }; + } + + let cm: Lrc = Default::default(); + let comments = SingleThreadedComments::default(); + let source_file = cm.new_source_file( + Lrc::new(FileName::Custom(options.file_name.clone())), + source.to_string(), + ); + let tsx_enabled = is_tsx(&options.file_name); + let lexer = Lexer::new( + Syntax::Typescript(TsSyntax { + tsx: tsx_enabled, + decorators: true, + dts: false, + no_early_errors: false, + disallow_ambiguous_jsx_like: false, + }), + EsVersion::EsNext, + StringInput::from(&*source_file), + Some(&comments), + ); + let mut parser = Parser::new_from(lexer); + let module = match parser.parse_module() { + Ok(module) => module, + Err(error) => { + let span = error.span(); + diagnostics.push(Diagnostic::parse( + 1000, + error.kind().msg().to_string(), + span.lo.0, + span.hi.0, + )); + return TranspileOutput { + output_text: String::new(), + diagnostics: options.report_diagnostics.then_some(diagnostics), + }; + } + }; + for error in parser.take_errors() { + let span = error.span(); + diagnostics.push(Diagnostic::parse( + 1000, + error.kind().msg().to_string(), + span.lo.0, + span.hi.0, + )); + } + + let mut program = Program::Module(module); + GLOBALS.set(&Globals::new(), || { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + resolver(unresolved_mark, top_level_mark, true).process(&mut program); + if tsx_enabled { + let tsx_config = TsxConfig { + pragma: options.compiler_options.jsx_factory.clone().map(Into::into), + pragma_frag: options + .compiler_options + .jsx_fragment_factory + .clone() + .map(Into::into), + }; + tsx( + cm.clone(), + TypeScriptConfig::default(), + tsx_config, + comments.clone(), + unresolved_mark, + top_level_mark, + ) + .process(&mut program); + + if options.compiler_options.jsx == Some(JSX_REACT) { + let mut react_options = ReactOptions { + runtime: Some(ReactRuntime::Classic), + pragma: options.compiler_options.jsx_factory.clone().map(Into::into), + pragma_frag: options + .compiler_options + .jsx_fragment_factory + .clone() + .map(Into::into), + ..Default::default() + }; + // Preserve the established classic-runtime behavior rather + // than opting into Babel 8 defaults implicitly. + react_options.next = Some(false); + react( + cm.clone(), + Some(comments.clone()), + react_options, + top_level_mark, + unresolved_mark, + ) + .process(&mut program); + } + } else { + typescript(TypeScriptConfig::default(), unresolved_mark, top_level_mark) + .process(&mut program); + } + }); + + let mut output = Vec::new(); + let emit_result = { + let mut emitter = Emitter { + cfg: CodegenConfig::default().with_target(EsVersion::EsNext), + cm: cm.clone(), + comments: Some(&comments), + wr: JsWriter::new(cm, "\n", &mut output, None), + }; + emitter.emit_program(&program) + }; + if let Err(error) = emit_result { + diagnostics.push(Diagnostic::error( + 90007, + format!("Failed to emit JavaScript: {error}"), + )); + } + let output_text = String::from_utf8(output).unwrap_or_default(); + + TranspileOutput { + output_text, + diagnostics: options.report_diagnostics.then_some(diagnostics), + } +} + +fn flatten_message(value: &Value, new_line: &str, indent: usize) -> String { + match value { + Value::String(message) => message.clone(), + Value::Null => String::new(), + Value::Object(object) => { + let mut result = object + .get("messageText") + .map(|message| flatten_message(message, new_line, indent)) + .unwrap_or_default(); + if let Some(Value::Array(children)) = object.get("next") { + for child in children { + if !result.is_empty() { + result.push_str(new_line); + } + result.push_str(&" ".repeat(indent + 1)); + result.push_str(&flatten_message(child, new_line, indent + 1)); + } + } + result + } + other => other.to_string(), + } +} + +/// `typescript.transpileModule(input, options)`. +/// +/// Returns a JSON string which codegen converts back into the TypeScript +/// `{ outputText, diagnostics? }` object. +/// +/// # Safety +/// +/// `source_ptr` must be null or point to a Perry runtime string. `options` is +/// a NaN-boxed JavaScript value passed through an `f64` ABI slot. +#[no_mangle] +pub unsafe extern "C" fn js_typescript_transpile_module( + source_ptr: *const StringHeader, + options: f64, +) -> *mut StringHeader { + let source = read_string(JsString::from_raw(source_ptr as *mut StringHeader)) + .map(str::to_owned) + .unwrap_or_default(); + let options_json = json_stringify(JsValue::from_bits(options.to_bits())); + let result = transpile(&source, options_json.as_deref()); + let json = serde_json::to_string(&result).unwrap_or_else(|error| { + format!( + "{{\"outputText\":\"\",\"diagnostics\":[{{\"category\":1,\"code\":90008,\"messageText\":{}}}]}}", + serde_json::to_string(&error.to_string()).unwrap_or_else(|_| "\"serialization failed\"".to_string()) + ) + }); + alloc_string(&json).as_raw() +} + +/// `typescript.flattenDiagnosticMessageText(messageText, newLine, indent?)`. +/// +/// # Safety +/// +/// `new_line_ptr` must be null or point to a Perry runtime string. `message` +/// is a NaN-boxed string or diagnostic-chain object. +#[no_mangle] +pub unsafe extern "C" fn js_typescript_flatten_diagnostic_message_text( + message: f64, + new_line_ptr: *const StringHeader, + indent: f64, +) -> *mut StringHeader { + let new_line = read_string(JsString::from_raw(new_line_ptr as *mut StringHeader)) + .map(str::to_owned) + .unwrap_or_else(|| "\n".to_string()); + let value = json_stringify(JsValue::from_bits(message.to_bits())) + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or(Value::Null); + let flattened = flatten_message(&value, &new_line, indent.max(0.0) as usize); + alloc_string(&flattened).as_raw() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opencode_options() -> &'static str { + r#"{"reportDiagnostics":true,"compilerOptions":{"target":99,"module":99}}"# + } + + #[test] + fn erases_types_and_preserves_async_for_opencode() { + let result = transpile( + "async function __codemode__() { const value: number = await Promise.resolve(1); return value as number; }", + Some(opencode_options()), + ); + assert!(result.diagnostics.as_ref().is_some_and(Vec::is_empty)); + assert!(result.output_text.contains("async function __codemode__()")); + assert!(result + .output_text + .contains("const value = await Promise.resolve(1)")); + assert!(!result.output_text.contains(": number")); + assert!(!result.output_text.contains("as number")); + } + + #[test] + fn preserves_es_module_imports() { + let result = transpile( + "import { value } from './dep.js'; export const answer: number = value;", + Some(opencode_options()), + ); + assert!(result + .output_text + .contains("import { value } from './dep.js'")); + assert!(result.output_text.contains("export const answer = value")); + } + + #[test] + fn reports_parser_diagnostics() { + let result = transpile("const value: = 1", Some(opencode_options())); + let diagnostics = result.diagnostics.expect("diagnostics requested"); + assert!(!diagnostics.is_empty()); + assert_eq!(diagnostics[0].category, DIAGNOSTIC_ERROR); + assert!(!diagnostics[0].message_text.is_empty()); + } + + #[test] + fn filename_enables_tsx_and_react_lowering() { + let result = transpile( + "export const node: JSX.Element =
hi
;", + Some( + r#"{"fileName":"snippet.tsx","reportDiagnostics":true,"compilerOptions":{"target":99,"module":99,"jsx":2}}"#, + ), + ); + assert!(result.diagnostics.as_ref().is_some_and(Vec::is_empty)); + assert!(result.output_text.contains("React.createElement")); + assert!(!result.output_text.contains("JSX.Element")); + } + + #[test] + fn unsupported_compiler_options_are_explicit() { + let result = transpile( + "const value: number = 1", + Some(r#"{"reportDiagnostics":true,"compilerOptions":{"target":1,"module":1}}"#), + ); + assert!(result.output_text.is_empty()); + let diagnostics = result.diagnostics.expect("diagnostics requested"); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics[0].message_text.contains("ScriptTarget")); + assert!(diagnostics[1].message_text.contains("ModuleKind")); + } + + #[test] + fn compatibility_errors_do_not_depend_on_report_diagnostics() { + let result = transpile( + "const value: number = 1", + Some(r#"{"compilerOptions":{"module":1}}"#), + ); + let diagnostics = result.diagnostics.expect("compatibility diagnostic"); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message_text.contains("ModuleKind")); + } + + #[test] + fn flattens_diagnostic_message_chains() { + let value = serde_json::json!({ + "messageText": "outer", + "next": [{ "messageText": "inner" }] + }); + assert_eq!(flatten_message(&value, "\n", 0), "outer\n inner"); + } +} diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 677ee222ab..07ff2a052e 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1,8 +1,5 @@ -//! AST to HIR lowering — extracted from `lower/mod.rs` (issue #1101). -//! -//! Pure mechanical split: no logic changes. Helpers keep their original -//! visibility and are re-exported from `lower/mod.rs` so the existing -//! `expr_*` submodules and the rest of the crate keep compiling unchanged. +//! AST to HIR module-declaration lowering, with topical helpers split into +//! sibling modules. use crate::types::{LocalId, Type}; use anyhow::Result; @@ -12,10 +9,10 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; -// Topical sub-modules extracted from this file (issue #1435 — pure code move). mod namespace; mod native_default_import; pub(super) mod native_profile_import; +mod typescript; // Re-export moved items so existing `crate::...` / `super::*` call paths keep // resolving. `lower_namespace_as_class` is also called from `lower/stmt.rs`. @@ -188,11 +185,11 @@ pub(crate) fn lower_module_decl( .unwrap_or_else(|| local.clone()); if is_native { let is_node_core = perry_api_manifest::is_node_core_module(&source); - if is_node_core - && !perry_api_manifest::module_has_public_named_export( - &source, &imported, - ) - { + if typescript::should_defer_unknown_named_import( + &source, + &imported, + is_node_core, + ) { // Almost always a TS TYPE in a mixed import — // `import { createCipheriv, BinaryLike } from "crypto"`. // tsc/esbuild/Bun all erase such a specifier; bailing here @@ -279,6 +276,9 @@ pub(crate) fn lower_module_decl( }); } } + typescript::register_runtime_enum( + ctx, module, &source, &local, &imported, + ); } else { if is_node_builtin_module(&source) { ctx.register_builtin_named_import( diff --git a/crates/perry-hir/src/lower/module_decl/typescript.rs b/crates/perry-hir/src/lower/module_decl/typescript.rs new file mode 100644 index 0000000000..e096faf102 --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/typescript.rs @@ -0,0 +1,98 @@ +use crate::ir::{Enum, EnumMember, EnumValue, Module}; +use crate::lower::LoweringContext; + +/// TypeScript is intentionally a strict partial compatibility package (#8511), +/// so apply the public-named-export gate used by Node core. The caller defers +/// the error until a value read because mixed imports can contain erased types. +pub(super) fn should_defer_unknown_named_import( + source: &str, + imported: &str, + is_node_core: bool, +) -> bool { + (is_node_core || source == "typescript") + && !perry_api_manifest::module_has_public_named_export(source, imported) +} + +/// Register TypeScript's runtime enums as HIR enums so OpenCode imports such as +/// `ScriptTarget.ESNext` fold without embedding the upstream compiler object. +pub(super) fn register_runtime_enum( + ctx: &mut LoweringContext, + module: &mut Module, + source: &str, + local: &str, + imported: &str, +) { + if source != "typescript" { + return; + } + let Some(runtime_members) = runtime_enum_members(imported) else { + return; + }; + let enum_id = ctx.fresh_enum(); + let members: Vec = runtime_members + .iter() + .map(|(name, value)| EnumMember { + name: (*name).to_string(), + value: EnumValue::Number(*value), + }) + .collect(); + ctx.define_enum( + local.to_string(), + enum_id, + members + .iter() + .map(|member| (member.name.clone(), member.value.clone())) + .collect(), + ); + module.enums.push(Enum { + id: enum_id, + name: local.to_string(), + members, + is_exported: false, + }); +} + +fn runtime_enum_members(name: &str) -> Option<&'static [(&'static str, i64)]> { + match name { + "ScriptTarget" => Some(&[ + ("ES3", 0), + ("ES5", 1), + ("ES2015", 2), + ("ES2016", 3), + ("ES2017", 4), + ("ES2018", 5), + ("ES2019", 6), + ("ES2020", 7), + ("ES2021", 8), + ("ES2022", 9), + ("ES2023", 10), + ("ES2024", 11), + ("ESNext", 99), + ("Latest", 99), + ("JSON", 100), + ]), + "ModuleKind" => Some(&[ + ("None", 0), + ("CommonJS", 1), + ("AMD", 2), + ("UMD", 3), + ("System", 4), + ("ES2015", 5), + ("ES2020", 6), + ("ES2022", 7), + ("ESNext", 99), + ("Node16", 100), + ("Node18", 101), + ("Node20", 102), + ("NodeNext", 199), + ("Preserve", 200), + ]), + "DiagnosticCategory" => Some(&[ + ("Warning", 0), + ("Error", 1), + ("Suggestion", 2), + ("Message", 3), + ]), + _ => None, + } +} diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index eb89365c2f..f5d8de263d 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1175,3 +1175,48 @@ fn test_user_class_animate_method_is_not_hijacked_as_a_widget_call() { method call: {dump}" ); } + +/// #8511: OpenCode's named imports from `typescript` must route to the native +/// transpiler and fold the three compiler enums without loading the upstream +/// TypeScript compiler namespace. +#[test] +fn typescript_transpile_subset_lowers_to_native_dispatch_and_enums() { + let source = r#" + import { + DiagnosticCategory, + ModuleKind, + ScriptTarget, + flattenDiagnosticMessageText, + transpileModule, + } from "typescript"; + + const result = transpileModule("const answer: number = 42", { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }); + const error = result.diagnostics?.find( + (item: any) => item.category === DiagnosticCategory.Error, + ); + if (error) console.log(flattenDiagnosticMessageText(error.messageText, "\n")); + "#; + let module = perry_parser::parse_typescript(source, "codemode.ts").expect("source parses"); + let hir = super::lower_module(&module, "codemode", "codemode.ts").expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + dump.contains("module: \"typescript\"") && dump.contains("method: \"transpileModule\""), + "transpileModule must use TypeScript native dispatch: {dump}" + ); + assert!( + dump.contains("enum_name: \"ScriptTarget\", member_name: \"ESNext\"") + && dump.contains("enum_name: \"ModuleKind\", member_name: \"ESNext\"") + && dump.contains("enum_name: \"DiagnosticCategory\", member_name: \"Error\""), + "TypeScript runtime enums must lower as HIR enum members: {dump}" + ); + assert!( + dump.contains("method: \"flattenDiagnosticMessageText\""), + "diagnostic flattening must use TypeScript native dispatch: {dump}" + ); +} diff --git a/crates/perry-hir/tests/unimplemented_api_check.rs b/crates/perry-hir/tests/unimplemented_api_check.rs index 217fc516d7..a9120201d7 100644 --- a/crates/perry-hir/tests/unimplemented_api_check.rs +++ b/crates/perry-hir/tests/unimplemented_api_check.rs @@ -212,6 +212,26 @@ fn crypto_unknown_method_is_rejected() { ); } +/// #8511: Perry intentionally exposes only the TypeScript runtime surface +/// audited for OpenCode Code Mode. Other compiler APIs must keep using the +/// ordinary strict manifest refusal rather than silently behaving as stubs. +#[test] +fn typescript_compiler_api_outside_native_subset_is_rejected() { + let result = lower_result_strict( + r#" + import { createProgram } from "typescript"; + createProgram([]); + "#, + ); + let error = result.expect_err("typescript.createProgram must be outside the native subset"); + assert!( + error.contains("typescript") + && error.contains("createProgram") + && error.contains("does not provide an export named"), + "unexpected strict manifest error: {error}" + ); +} + /// #5245: the default (non-strict) mode defers a recognized-but-unimplemented /// API call to a throw-on-reach runtime error instead of failing the build, /// rather than the historical hard `#463` refusal. `process.binding('buffer')` diff --git a/crates/perry/tests/issue_8511_typescript_transpile_module.rs b/crates/perry/tests/issue_8511_typescript_transpile_module.rs new file mode 100644 index 0000000000..584fc2ed4b --- /dev/null +++ b/crates/perry/tests/issue_8511_typescript_transpile_module.rs @@ -0,0 +1,89 @@ +//! End-to-end regression coverage for #8511. OpenCode Code Mode imports a +//! narrow runtime surface from `typescript`; Perry must route it to the native +//! SWC-backed binding instead of compiling or embedding TypeScript's compiler. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn opencode_typescript_transpile_subset_compiles_and_runs() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +import { + DiagnosticCategory, + ModuleKind, + ScriptTarget, + flattenDiagnosticMessageText, + transpileModule, +} from "typescript"; + +const compilerOptions = { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, +}; +const transpiled = transpileModule( + "async function __codemode__() { const value: number = await Promise.resolve(1); return value as number; }", + { reportDiagnostics: true, compilerOptions }, +); +const diagnostic = transpiled.diagnostics?.find( + (item: any) => item.category === DiagnosticCategory.Error, +); + +const invalid = transpileModule("const value: = 1", { + reportDiagnostics: true, + compilerOptions, +}); +const invalidDiagnostic = invalid.diagnostics?.find( + (item: any) => item.category === DiagnosticCategory.Error, +); + +console.log([ + diagnostic === undefined, + transpiled.outputText.includes("async function __codemode__()"), + transpiled.outputText.includes("const value = await Promise.resolve(1)"), + !transpiled.outputText.includes(": number"), + flattenDiagnosticMessageText(invalidDiagnostic?.messageText ?? "", "\n").length > 0, +].join("|")); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "true|true|true|true|true\n" + ); +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index d0900e42a3..dea9fa4fd5 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -717,3 +717,19 @@ crate = "perry-ext-parcel-watcher" lib = "perry_ext_parcel_watcher" tracking = "#8513" alias-of = "@parcel/watcher" +# `typescript` — only the runtime transpilation API audited for OpenCode Code +# Mode. Perry uses SWC to erase TS/TSX syntax and emit JavaScript; it does not +# compile or embed Microsoft's complete type checker/compiler bundle. +[bindings.typescript] +crate = "perry-ext-typescript" +lib = "perry_ext_typescript" +tracking = "#8511" +compat = "partial" + +[bindings.typescript.upstream] +version = "5.8.2" +sha256 = "ef938a45323df5775664ea5d55e8bc0ab2027a40db1ff857bb957fe7bbaa4434" +repo = "https://github.com/microsoft/TypeScript" +ref = "beb69e4cdd61b1a0fd9ae21ae58bd4bd409d7217" +ported-at = "5.8.2" +date = "2026-08-21" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 61bb8d7aa9..5a9423d12b 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -3992,6 +3992,19 @@ declare module "tursodb" { export function open(...args: any[]): any; } +declare module "typescript" { + /** stdlib */ + export const DiagnosticCategory: any; + /** stdlib */ + export const ModuleKind: any; + /** stdlib */ + export const ScriptTarget: any; + /** stdlib */ + export function flattenDiagnosticMessageText(...args: any[]): any; + /** stdlib */ + export function transpileModule(...args: any[]): any; +} + declare module "undici" { /** stdlib */ export class Agent { [key: string]: any; } diff --git a/workspace-architecture.json b/workspace-architecture.json index 839ee9e9fd..de57e1f6b4 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 79, + "workspace_members": 80, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -66,7 +66,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 32, + "externalize": 33, "keep": 42, "merge": 1, "remove": 1, @@ -278,6 +278,10 @@ "category": "binding", "decision": "keep" }, + "perry-ext-typescript": { + "category": "binding", + "decision": "externalize" + }, "perry-ext-uuid": { "category": "binding", "decision": "externalize"