From f21b91fcd01c675d7bde9d615919b344184eb4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 05:11:02 +0200 Subject: [PATCH 1/5] fix(cjs): expose current exports during require cycles --- changelog.d/10178-cjs-cycle-exports.md | 12 +++ .../native/native_runtime_branch.rs | 4 +- crates/perry-runtime/src/module_require.rs | 9 +- .../src/module_require/path_registry.rs | 3 +- .../compile/cjs_wrap/preamble_canary_tests.rs | 7 ++ .../src/commands/compile/cjs_wrap/wrap.rs | 19 ++-- .../tests/source_graph_export_regressions.rs | 9 +- .../issue_10178.rs | 97 +++++++++++++++++++ test-files/cjs_esbuild_cycle/consumer.cjs | 24 +++++ test-files/cjs_esbuild_cycle/token.cjs | 30 ++++++ test-files/test_cjs_esbuild_cycle.ts | 12 +++ 11 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 changelog.d/10178-cjs-cycle-exports.md create mode 100644 crates/perry/tests/source_graph_export_regressions/issue_10178.rs create mode 100644 test-files/cjs_esbuild_cycle/consumer.cjs create mode 100644 test-files/cjs_esbuild_cycle/token.cjs create mode 100644 test-files/test_cjs_esbuild_cycle.ts diff --git a/changelog.d/10178-cjs-cycle-exports.md b/changelog.d/10178-cjs-cycle-exports.md new file mode 100644 index 0000000000..64c722cc17 --- /dev/null +++ b/changelog.d/10178-cjs-cycle-exports.md @@ -0,0 +1,12 @@ +Fix CommonJS cycle re-entry after `module.exports` is replaced, including +esbuild's `__export` / `__toCommonJS` getter exports and semver's exported +Comparator class. Partial publication now retains the module record, so the +existing require adapter reads its current exports instead of the initial +empty object. This adds no getter enumeration, copies, allocations, or extra +publication calls to ordinary CommonJS initialization. + +Generated circular-dependency warnings check whether the property actually +exists before warning, without invoking accessors. Regression coverage imports +two esbuild-style CommonJS modules from ESM, checks the module views during +cycle re-entry, calls through the cycle, and verifies live bindings and empty +stderr. Related: #10178, #10107. diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index 653483e59c..e4df635e5b 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -109,8 +109,8 @@ &[(DOUBLE, &from), (DOUBLE, &specifier)], )); } - // Next.js wall 54: publish a CJS module's partial exports before - // its body so same-thread recursive requires can observe them. + // Publish the CJS record before its body so same-thread recursive + // requires see its current exports, including replacements. "registerPathModulePartial" => { let path = args.first().map_or_else( || Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))), diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 68c646e5e9..f0be892bae 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -440,10 +440,9 @@ fn registered_path_module_value(path: &str) -> Option { .map(f64::from_bits) } -/// The registry holds whatever the CommonJS wrapper published: the module -/// RECORD once the wrapper reaches its tail, or bare partial exports while a -/// cycle is still initializing. Generated `require` sites want the exports in -/// both cases. +/// Generated wrappers publish the module RECORD at both the partial and final +/// boundaries. Read its current exports so replacements made before a cycle +/// re-entry are visible. Bare values remain supported for other publishers. fn path_module_exports(bits: u64) -> f64 { let value = f64::from_bits(bits); cjs_record_exports(value).unwrap_or(value) @@ -863,7 +862,7 @@ pub unsafe extern "C" fn js_register_path_init(path_ptr: *const u8, path_len: i6 } } -/// Codegen FFI: publish a CommonJS module's initial `exports` object before +/// Codegen FFI: publish a CommonJS module's record before /// executing its body. This is visible only to recursive loads by the owning /// thread; concurrent callers wait for [`js_register_path_module`] and the /// generated initializer to complete. diff --git a/crates/perry-runtime/src/module_require/path_registry.rs b/crates/perry-runtime/src/module_require/path_registry.rs index 490298bfd1..7e7fffcc84 100644 --- a/crates/perry-runtime/src/module_require/path_registry.rs +++ b/crates/perry-runtime/src/module_require/path_registry.rs @@ -183,7 +183,8 @@ impl PathModuleRegistry { true } - /// Publish the initial CommonJS `exports` object before the wrapper body. + /// Publish the CommonJS module record before the wrapper body. The exports + /// adapter unwraps its current `.exports` on each read, including cycles. /// Only same-thread recursive loads may observe it; unrelated waiters stay /// parked while the status is `Initializing`. pub(super) fn register_partial_exports(&self, key: String, exports: u64) -> bool { diff --git a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs index 88f08698cb..cbacddaefa 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs @@ -204,6 +204,13 @@ fn path_module_wrap_publishes_partial_then_final_exports_and_tracks_undefined() .rfind("__perry_register_path_module(") .expect("CJS wrapper must publish its final module.exports value"); assert!(partial < body && body < final_publish, "{wrapped}"); + assert!( + wrapped.contains(&format!( + "__perry_register_path_module_partial({:?}, __cjs_module);", + path.to_string_lossy() + )), + "cycle readers must follow module.exports replacements\n{wrapped}" + ); // #8040: both the value lookup and the presence probe must consult the // SAME resolved specifier. A computed relative request is joined against // the module's directory before either call (`__perry_path_spec`), so a diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 5ce09a30d5..40f74665a2 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -467,8 +467,13 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( cyclic_missing_property_names(source, source_path, spec, target) .into_iter() .map(|property| { + // #10178: this scan only nominates possible + // misses. __export helpers and class statics + // can already be present on a replacement + // module.exports. Check the returned value + // without invoking the exported getter. format!( - "if (childBefore && childBefore.loaded === false) globalThis.process?.emitWarning?.(\"Accessing non-existent property '{property}' of module exports inside circular dependency\"); " + "if (childBefore && childBefore.loaded === false && required != null && (typeof required === 'object' || typeof required === 'function') && !('{property}' in required)) globalThis.process?.emitWarning?.(\"Accessing non-existent property '{property}' of module exports inside circular dependency\"); " ) }) .collect::() @@ -476,7 +481,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( String::new() }; format!( - "const childBefore = require.cache[{path:?}]; {warnings}globalThis.__perry_cjs_pending_parent = module; let required; try {{ required = __perry_require_path_module({path:?}); }} finally {{ globalThis.__perry_cjs_pending_parent = undefined; }} {link_child}return required;", + "const childBefore = require.cache[{path:?}]; globalThis.__perry_cjs_pending_parent = module; let required; try {{ required = __perry_require_path_module({path:?}); }} finally {{ globalThis.__perry_cjs_pending_parent = undefined; }} {warnings}{link_child}return required;", path = target.to_string_lossy(), ) }) @@ -979,10 +984,12 @@ pub(in crate::commands::compile) fn wrap_commonjs_with_body_offset( // Node populates `module.parent` before the body evaluates, so link it // here rather than at the tail's registry publication. __perry_link_path_module_parent(__cjs_module); - // Publish the initial exports before user code. The runtime exposes them - // only to same-thread recursive loads; concurrent first callers wait for - // the final record registration at the bottom of this wrapper. - __perry_register_path_module_partial({module_path_literal}, __cjs_module.exports); + // Publish the record before user code, just as at final publication. + // #10178: esbuild replaces module.exports before requiring its peer. + // Holding the initial empty object loses that replacement at re-entry; + // the runtime's existing record unwrap reads the current exports instead. + // This needs no getter enumeration, copying, or extra publication calls. + __perry_register_path_module_partial({module_path_literal}, __cjs_module); var module = __cjs_module; var exports = __cjs_module.exports; const __perry_cjs_base_require = __perry_cjs_create_require({module_filename_literal}); diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 4797a6c6a9..58df217fed 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -9,6 +9,9 @@ use std::sync::Once; #[path = "source_graph_export_regressions/issue_10153.rs"] mod issue_10153; +#[path = "source_graph_export_regressions/issue_10178.rs"] +mod issue_10178; + const GC_ENV_OVERRIDES: &[&str] = &[ "PERRY_GEN_GC", "PERRY_GC_SCAVENGE", @@ -90,6 +93,10 @@ fn write(dir: &Path, name: &str, source: &str) { } fn compile_and_run(dir: &Path, entry: &str) -> String { + String::from_utf8_lossy(&compile_and_run_output(dir, entry).stdout).into_owned() +} + +fn compile_and_run_output(dir: &Path, entry: &str) -> std::process::Output { let output = dir.join("main_bin"); let compile = Command::new(perry_bin()) .current_dir(dir) @@ -119,7 +126,7 @@ fn compile_and_run(dir: &Path, entry: &str) -> String { String::from_utf8_lossy(&run.stdout), String::from_utf8_lossy(&run.stderr) ); - String::from_utf8_lossy(&run.stdout).into_owned() + run } fn compile_and_run_with_llvm_trace(dir: &Path, entry: &str) -> (String, String) { diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10178.rs b/crates/perry/tests/source_graph_export_regressions/issue_10178.rs new file mode 100644 index 0000000000..d7e28ab528 --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10178.rs @@ -0,0 +1,97 @@ +//! A CJS cycle must observe module.exports replacements before init finishes. + +use super::{compile_and_run_output, write}; + +#[test] +fn esbuild_getters_are_callable_and_live_through_a_require_cycle() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "token.cjs", + include_str!("../../../../test-files/cjs_esbuild_cycle/token.cjs"), + ); + write( + dir.path(), + "consumer.cjs", + include_str!("../../../../test-files/cjs_esbuild_cycle/consumer.cjs"), + ); + write( + dir.path(), + "main.mjs", + &include_str!("../../../../test-files/test_cjs_esbuild_cycle.ts") + .replace("./cjs_esbuild_cycle/token.cjs", "./token.cjs"), + ); + let run = compile_and_run_output(dir.path(), "main.mjs"); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "function:function:true\n0\n42\n52\n2\n" + ); + assert!( + run.stderr.is_empty(), + "cycle emitted a warning: {}", + String::from_utf8_lossy(&run.stderr) + ); +} + +#[test] +fn class_static_getter_is_visible_in_a_comparator_first_cycle() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "comparator.cjs", + "const ANY = Symbol('ANY');\n\ + class Comparator { static get ANY() { return ANY; } }\n\ + module.exports = Comparator;\n\ + const Range = require('./range.cjs');\n\ + Comparator.read = function () { return Range.read(); };\n", + ); + write( + dir.path(), + "range.cjs", + "class Range { static read() { return Comparator.ANY; } }\n\ + module.exports = Range;\n\ + const Comparator = require('./comparator.cjs');\n", + ); + write( + dir.path(), + "main.mjs", + "import Comparator from './comparator.cjs';\n\ + console.log(typeof Comparator.read());\n\ + console.log(Comparator.read() === Comparator.ANY);\n", + ); + let run = compile_and_run_output(dir.path(), "main.mjs"); + assert_eq!(String::from_utf8_lossy(&run.stdout), "symbol\ntrue\n"); + assert!( + run.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); +} + +#[test] +fn missing_property_in_a_cycle_still_warns() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "a.cjs", + "exports.before = true;\n\ + const b = require('./b.cjs');\n\ + exports.seen = b.seen;\n\ + exports.after = true;\n", + ); + write( + dir.path(), + "b.cjs", + "const a = require('./a.cjs');\nexports.seen = a.after;\n", + ); + write( + dir.path(), + "main.mjs", + "import a from './a.cjs';\nconsole.log(a.seen);\n", + ); + let run = compile_and_run_output(dir.path(), "main.mjs"); + assert_eq!(String::from_utf8_lossy(&run.stdout), "undefined\n"); + assert!(String::from_utf8_lossy(&run.stderr).contains( + "Accessing non-existent property 'after' of module exports inside circular dependency" + )); +} diff --git a/test-files/cjs_esbuild_cycle/consumer.cjs b/test-files/cjs_esbuild_cycle/consumer.cjs new file mode 100644 index 0000000000..14e3042435 --- /dev/null +++ b/test-files/cjs_esbuild_cycle/consumer.cjs @@ -0,0 +1,24 @@ +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from) => { + for (let key of __getOwnPropNames(from)) + __defProp(to, key, { get: () => from[key], enumerable: true }); + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var consumer_exports = {}; +__export(consumer_exports, { read: () => read, cycleView: () => cycleView }); +module.exports = __toCommonJS(consumer_exports); +var token = require("./token.cjs"); +// Inspect both views while token's wrapper is still running. Neither +// descriptor inspection nor retaining the namespace should invoke a getter. +var record = globalThis.__esbuildCycleRecord; +var cachedGetter = record ? typeof Object.getOwnPropertyDescriptor(record.exports, "getValue")?.get : "missing-record"; +var requiredGetter = token ? typeof Object.getOwnPropertyDescriptor(token, "getValue")?.get : "missing-exports"; +var sameExports = record ? token === record.exports : false; +function read() { return (0, token.getValue)(); } +function cycleView() { return cachedGetter + ":" + requiredGetter + ":" + sameExports; } diff --git a/test-files/cjs_esbuild_cycle/token.cjs b/test-files/cjs_esbuild_cycle/token.cjs new file mode 100644 index 0000000000..7db8786ca8 --- /dev/null +++ b/test-files/cjs_esbuild_cycle/token.cjs @@ -0,0 +1,30 @@ +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from) => { + for (let key of __getOwnPropNames(from)) + __defProp(to, key, { get: () => from[key], enumerable: true }); + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var token_exports = {}; +var getterReads = 0; +__export(token_exports, { + getValue: () => (getterReads++, getValue), + callThroughCycle: () => callThroughCycle, + update: () => update, + cycleView: () => cycleView, + reads: () => reads +}); +module.exports = __toCommonJS(token_exports); +globalThis.__esbuildCycleRecord = module; +var consumer = require("./consumer.cjs"); +var offset = 40; +function getValue() { return offset + 2; } +function callThroughCycle() { return (0, consumer.read)(); } +function update() { getValue = function () { return 52; }; } +function cycleView() { return consumer.cycleView(); } +function reads() { return getterReads; } diff --git a/test-files/test_cjs_esbuild_cycle.ts b/test-files/test_cjs_esbuild_cycle.ts new file mode 100644 index 0000000000..3373f7faa4 --- /dev/null +++ b/test-files/test_cjs_esbuild_cycle.ts @@ -0,0 +1,12 @@ +// #10178: an ESM import enters an esbuild CommonJS require cycle. +import process from "node:process"; +import token from "./cjs_esbuild_cycle/token.cjs"; + +if (typeof process.emitWarning !== "function") throw new Error("missing warning support"); +console.log(token.cycleView()); +console.log(token.reads()); +delete globalThis.__esbuildCycleRecord; +console.log(token.callThroughCycle()); +token.update(); +console.log(token.callThroughCycle()); +console.log(token.reads()); From 69f9333886249544fecc50c81118124017796828 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 15 Sep 2026 02:51:29 +0000 Subject: [PATCH 2/5] fix(resolve): rank the bun export condition above node for --platform bun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun resolves package `exports` and `imports` with `["bun", "node", …]`. Perry's resolvers used `["perry", "node", "import", "module", "default", "require"]` with no `bun` entry at all, so a package shipping both entries compiled its node build even when the compile targeted bun. `@opentui/core` is the case that surfaced it: its exports are `{ bun: ./index.bun.js, node: ./index.node.js, import: ./index.node.js }`, and its two entries load different renderer backends. OpenCode's terminal interface reached the node backend and threw `TypeError: value is not a function` during the dynamic import of its layer module, while the official bun binary runs the bun entry. A module-body trace over the whole subgraph showed perry initializing `index.node.js` and its node chunks where bun initialized `index.bun.js`. `bun` is ranked directly after `perry`, so an explicit perry entry still wins, and above `node` only when the target is bun. The three resolvers (`resolve_exports`, `resolve_exports_candidates`, `resolve_subpath_import`) now read one shared accessor so they cannot disagree, which the existing comments already required of them. Refs #10107. --- changelog.d/10281-bun-export-condition.md | 10 ++++++ crates/perry/src/commands/compile/resolve.rs | 12 +++---- .../compile/resolve/subpath_imports.rs | 35 +++++++++++++++++-- .../src/commands/compile/run_pipeline.rs | 4 +++ crates/perry/src/commands/deps.rs | 4 +-- 5 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 changelog.d/10281-bun-export-condition.md diff --git a/changelog.d/10281-bun-export-condition.md b/changelog.d/10281-bun-export-condition.md new file mode 100644 index 0000000000..a833e3d429 --- /dev/null +++ b/changelog.d/10281-bun-export-condition.md @@ -0,0 +1,10 @@ +Rank the `bun` condition above `node` when resolving package `exports` and +`imports` under `--platform bun`. Bun resolves with `["bun", "node", …]`, so a +package that ships both entries — `@opentui/core` offers `{ bun: +./index.bun.js, node: ./index.node.js, import: ./index.node.js }` — previously +compiled its node entry and ran a different backend than the bun binary the +program is meant to match. OpenCode's terminal interface died on exactly that: +the node entry's renderer backend is not the one its bun build loads. `bun` +stays below `perry`, so an explicit perry entry still wins, and the order is +unchanged for every other target. The three resolvers now read one shared +list so they cannot disagree. diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 190bc1ae87..b77abcb267 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -989,11 +989,7 @@ fn resolve_exports_with_conditions( // documented single-result entry point (mirrors `resolve_subpath_import`). #[allow(dead_code)] pub(super) fn resolve_exports(exports: &serde_json::Value, subpath: &str) -> Option { - resolve_exports_with_conditions( - exports, - subpath, - &["perry", "node", "import", "module", "default", "require"], - ) + resolve_exports_with_conditions(exports, subpath, subpath_imports::default_conditions()) } /// Like [`resolve_exports`], but returns EVERY condition branch's resolution @@ -1012,7 +1008,7 @@ pub(super) fn resolve_exports_candidates( // traversePathUp only in `./node.js`; resolving `./default.js` left them // undefined → npm-run-path → execa LINK failure.) Mirrors `resolve_exports` // / `resolve_subpath_import`. - const CONDITIONS: &[&str] = &["perry", "node", "import", "module", "default", "require"]; + fn collect(value: &serde_json::Value, subpath: &str, out: &mut Vec) { match value { serde_json::Value::String(s) if !out.contains(s) => { @@ -1060,7 +1056,7 @@ pub(super) fn resolve_exports_candidates( } } } - for condition in CONDITIONS { + for condition in subpath_imports::default_conditions() { if let Some(entry) = map.get(*condition) { collect(entry, subpath, out); } @@ -1447,7 +1443,7 @@ pub(super) fn resolve_import_with_bunfs( match subpath_imports::resolve_subpath_import( import_source, importer_path, - subpath_imports::DEFAULT_CONDITIONS, + subpath_imports::default_conditions(), ) { Ok(SubpathImportOutcome::File(canonical)) => Some(canonical), // Bare-package target: re-enter resolution with the mapped diff --git a/crates/perry/src/commands/compile/resolve/subpath_imports.rs b/crates/perry/src/commands/compile/resolve/subpath_imports.rs index 704e7ad6c3..71f1cbc1f1 100644 --- a/crates/perry/src/commands/compile/resolve/subpath_imports.rs +++ b/crates/perry/src/commands/compile/resolve/subpath_imports.rs @@ -53,8 +53,39 @@ use super::{normalize_path_lexically, resolve_with_extensions}; /// (`resolve_exports` / `resolve_exports_candidates`) exactly — including /// ranking `node` above `default`, so a `{ node, default: browser }` /// conditional pair picks the node build for native compilation. -pub(crate) const DEFAULT_CONDITIONS: &[&str] = - &["perry", "node", "import", "module", "default", "require"]; +const NODE_CONDITIONS: &[&str] = &["perry", "node", "import", "module", "default", "require"]; + +/// The same order with `bun` ranked directly after `perry`, used when the +/// compile targets `--platform bun` (#10281). Bun resolves with `["bun", +/// "node", ...]`, so a package that ships both — `@opentui/core` offers +/// `{ bun: ./index.bun.js, node: ./index.node.js, import: ./index.node.js }` — +/// must resolve its bun entry or the program runs a different backend than the +/// bun binary it is meant to match. `bun` stays BELOW `perry` so an explicit +/// perry entry still wins, and above `node` only for this target. +const BUN_CONDITIONS: &[&str] = + &["perry", "bun", "node", "import", "module", "default", "require"]; + +/// Whether this compile targets `--platform bun`. Set once from +/// `CompilationContext::bun_platform` before module collection; a compile is a +/// single process with a single target, so a process-wide flag is the whole +/// state. Read on the already cold resolution path. +static BUN_PLATFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Record the target platform for export/import condition resolution. +pub(crate) fn set_bun_platform(on: bool) { + BUN_PLATFORM.store(on, std::sync::atomic::Ordering::Relaxed); +} + +/// Conditions accepted when matching conditional targets, in priority order. +/// `resolve_exports`, `resolve_exports_candidates` and `resolve_subpath_import` +/// must all use this one source so the three resolvers cannot disagree. +pub(crate) fn default_conditions() -> &'static [&'static str] { + if BUN_PLATFORM.load(std::sync::atomic::Ordering::Relaxed) { + BUN_CONDITIONS + } else { + NODE_CONDITIONS + } +} /// Successful outcome of resolving a `#` subpath-import specifier. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index e25bd6ea3f..f956c10f47 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -764,6 +764,10 @@ pub fn run_with_parse_cache( let mut ctx = CompilationContext::new(project_root.clone()); ctx.bun_platform = args.platform == JavaScriptPlatform::Bun; + // #10281: the package `exports` / `imports` resolvers rank `bun` above + // `node` for this target, so a package shipping both entries resolves the + // one the bun binary would run. + resolve::subpath_imports::set_bun_platform(ctx.bun_platform); ctx.cache_root = object_cache_project_root(&args.input, &project_root); ctx.bunfs_root = match args.bunfs_root.as_deref() { Some(root) => { diff --git a/crates/perry/src/commands/deps.rs b/crates/perry/src/commands/deps.rs index 18ec7641cf..9e625b2f6c 100644 --- a/crates/perry/src/commands/deps.rs +++ b/crates/perry/src/commands/deps.rs @@ -145,9 +145,9 @@ impl DependencyResolver { // spec resolver the compiler uses (see resolve/subpath_imports.rs). if import_source.starts_with('#') { use super::compile::resolve::subpath_imports::{ - resolve_subpath_import, SubpathImportOutcome, DEFAULT_CONDITIONS, + default_conditions, resolve_subpath_import, SubpathImportOutcome, }; - match resolve_subpath_import(import_source, importing_file, DEFAULT_CONDITIONS) { + match resolve_subpath_import(import_source, importing_file, default_conditions()) { // Maps to a real file inside the package: resolved. Ok(SubpathImportOutcome::File(_)) => return, // Maps to a bare package specifier: verify THAT package the From e5e1e11c344f41e5780ed390cc9e238ba5aaf116 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 15 Sep 2026 05:08:34 +0200 Subject: [PATCH 3/5] test(resolve): pin the bun export condition in both directions (#10281) --- .../compile/resolve/subpath_imports.rs | 77 +++++++++-------- .../src/commands/compile/resolve/tests.rs | 4 + .../tests/bun_export_condition_tests.rs | 83 +++++++++++++++++++ 3 files changed, 129 insertions(+), 35 deletions(-) create mode 100644 crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs diff --git a/crates/perry/src/commands/compile/resolve/subpath_imports.rs b/crates/perry/src/commands/compile/resolve/subpath_imports.rs index 71f1cbc1f1..5a8aeee7a3 100644 --- a/crates/perry/src/commands/compile/resolve/subpath_imports.rs +++ b/crates/perry/src/commands/compile/resolve/subpath_imports.rs @@ -16,7 +16,7 @@ //! the longer total key (Node's `patternKeyCompare`). //! - Targets may be strings, arrays (first entry that resolves wins), or //! conditional objects. Conditions are matched in the fixed **priority -//! order** of [`DEFAULT_CONDITIONS`] +//! order** of [`default_conditions`] //! (`perry`/`node`/`import`/`module`/`default`/`require`) — the same model //! (and the same `node`-above-`default` ranking) as perry's package-`exports` //! resolver `resolve_exports`; the two resolvers must agree (a `{ node, @@ -62,8 +62,9 @@ const NODE_CONDITIONS: &[&str] = &["perry", "node", "import", "module", "default /// must resolve its bun entry or the program runs a different backend than the /// bun binary it is meant to match. `bun` stays BELOW `perry` so an explicit /// perry entry still wins, and above `node` only for this target. -const BUN_CONDITIONS: &[&str] = - &["perry", "bun", "node", "import", "module", "default", "require"]; +const BUN_CONDITIONS: &[&str] = &[ + "perry", "bun", "node", "import", "module", "default", "require", +]; /// Whether this compile targets `--platform bun`. Set once from /// `CompilationContext::bun_platform` before module collection; a compile is a @@ -153,7 +154,7 @@ impl std::error::Error for SubpathImportError {} /// /// `importer_path` is the file containing the import; the package scope is /// found by walking up from its directory. `conditions` is the active -/// condition set (callers normally pass [`DEFAULT_CONDITIONS`]). +/// condition set (callers normally pass [`default_conditions`]). pub(crate) fn resolve_subpath_import( specifier: &str, importer_path: &Path, @@ -333,7 +334,7 @@ fn resolve_target( } // Conditional object: try the active conditions in priority order // (the same model as `resolve_exports_with_conditions` — see the - // [`DEFAULT_CONDITIONS`] doc for why key order is not used). A branch + // [`default_conditions`] doc for why key order is not used). A branch // that fails to resolve falls through to the next condition. serde_json::Value::Object(map) => { for cond in conditions { @@ -530,8 +531,9 @@ mod tests { r##"{ "#config": "./src/config.ts" }"##, &["src/config.ts"], ); - let resolved = - expect_file(resolve_subpath_import("#config", &importer, DEFAULT_CONDITIONS).unwrap()); + let resolved = expect_file( + resolve_subpath_import("#config", &importer, default_conditions()).unwrap(), + ); assert!(resolved.ends_with("src/config.ts")); } @@ -544,8 +546,9 @@ mod tests { r##"{ "#lib/*": "./src/lib/*" }"##, &["src/lib/foo.ts"], ); - let resolved = - expect_file(resolve_subpath_import("#lib/foo", &importer, DEFAULT_CONDITIONS).unwrap()); + let resolved = expect_file( + resolve_subpath_import("#lib/foo", &importer, default_conditions()).unwrap(), + ); assert!(resolved.ends_with("src/lib/foo.ts")); } @@ -558,7 +561,7 @@ mod tests { &["src/lib/deep/util.ts", "src/deep/util.ts"], ); let resolved = expect_file( - resolve_subpath_import("#lib/deep/util", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#lib/deep/util", &importer, default_conditions()).unwrap(), ); // `#lib/deep/*` (longer non-wildcard prefix) must beat `#lib/*`. assert!(resolved.ends_with("src/deep/util.ts"), "{resolved:?}"); @@ -573,7 +576,7 @@ mod tests { &["src/special.ts", "src/lib/special.ts"], ); let resolved = expect_file( - resolve_subpath_import("#lib/special", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#lib/special", &importer, default_conditions()).unwrap(), ); assert!(resolved.ends_with("src/special.ts"), "{resolved:?}"); } @@ -588,7 +591,7 @@ mod tests { ); // `*` captures `helper`; the `.js` target probes the `.ts` source. let resolved = expect_file( - resolve_subpath_import("#internal/helper.js", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#internal/helper.js", &importer, default_conditions()).unwrap(), ); assert!(resolved.ends_with("src/internal/helper.ts"), "{resolved:?}"); } @@ -602,7 +605,7 @@ mod tests { &["src/env.node.ts", "src/env.default.ts"], ); let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.node.ts"), "{resolved:?}"); } @@ -616,7 +619,7 @@ mod tests { ); // `browser` is not in the active condition set → `default` wins. let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.default.ts"), "{resolved:?}"); } @@ -629,7 +632,7 @@ mod tests { &["src/env.mjs.ts", "src/env.cjs.ts"], ); let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.mjs.ts"), "{resolved:?}"); } @@ -642,7 +645,7 @@ mod tests { &["src/env.ts"], // dist/env.js intentionally absent ); let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.ts"), "{resolved:?}"); } @@ -655,7 +658,7 @@ mod tests { &["src/dep.ts"], ); let resolved = - expect_file(resolve_subpath_import("#dep", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#dep", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/dep.ts"), "{resolved:?}"); } @@ -663,7 +666,7 @@ mod tests { fn array_of_only_invalid_targets_propagates_error() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#dep": ["../out.ts", "/abs.ts"] }"##, &[]); - let err = resolve_subpath_import("#dep", &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import("#dep", &importer, default_conditions()).unwrap_err(); assert!(matches!(err, SubpathImportError::InvalidTarget { .. })); } @@ -671,7 +674,7 @@ mod tests { fn bare_package_target_returns_external() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#dep": "some-pkg" }"##, &[]); - let outcome = resolve_subpath_import("#dep", &importer, DEFAULT_CONDITIONS).unwrap(); + let outcome = resolve_subpath_import("#dep", &importer, default_conditions()).unwrap(); assert_eq!( outcome, SubpathImportOutcome::External("some-pkg".to_string()) @@ -687,7 +690,7 @@ mod tests { &[], ); let outcome = - resolve_subpath_import("#vendored/util", &importer, DEFAULT_CONDITIONS).unwrap(); + resolve_subpath_import("#vendored/util", &importer, default_conditions()).unwrap(); assert_eq!( outcome, SubpathImportOutcome::External("@scope/vendored/util".to_string()) @@ -702,9 +705,12 @@ mod tests { r##"{ "#vendored/*": "@scope/vendored/*" }"##, &[], ); - let err = - resolve_subpath_import("#vendored/../../etc/passwd", &importer, DEFAULT_CONDITIONS) - .unwrap_err(); + let err = resolve_subpath_import( + "#vendored/../../etc/passwd", + &importer, + default_conditions(), + ) + .unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidSpecifier { .. }), "{err}" @@ -715,7 +721,7 @@ mod tests { fn node_builtin_target_returns_external() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#fs": "node:fs" }"##, &[]); - let outcome = resolve_subpath_import("#fs", &importer, DEFAULT_CONDITIONS).unwrap(); + let outcome = resolve_subpath_import("#fs", &importer, default_conditions()).unwrap(); assert_eq!( outcome, SubpathImportOutcome::External("node:fs".to_string()) @@ -726,7 +732,7 @@ mod tests { fn parent_dir_target_is_rejected() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#escape": "../outside.ts" }"##, &[]); - let err = resolve_subpath_import("#escape", &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import("#escape", &importer, default_conditions()).unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidTarget { .. }), "{err}" @@ -741,7 +747,7 @@ mod tests { r##"{ "#escape": "./src/../../outside.ts" }"##, &[], ); - let err = resolve_subpath_import("#escape", &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import("#escape", &importer, default_conditions()).unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidTarget { .. }), "{err}" @@ -756,8 +762,9 @@ mod tests { r##"{ "#lib/*": "./src/lib/*" }"##, &["src/lib/foo.ts"], ); - let err = resolve_subpath_import("#lib/../../../etc/passwd", &importer, DEFAULT_CONDITIONS) - .unwrap_err(); + let err = + resolve_subpath_import("#lib/../../../etc/passwd", &importer, default_conditions()) + .unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidSpecifier { .. }), "{err}" @@ -769,7 +776,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#lib/*": "./src/lib/*" }"##, &[]); for bad in ["#", "#/", "#/foo", "#lib/"] { - let err = resolve_subpath_import(bad, &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import(bad, &importer, default_conditions()).unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidSpecifier { .. }), "{bad} should be invalid, got {err}" @@ -792,7 +799,7 @@ mod tests { &["lib/util.ts"], ); let resolved = expect_file( - resolve_subpath_import("#util", &nested_importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#util", &nested_importer, default_conditions()).unwrap(), ); assert!( resolved.ends_with("packages/inner/lib/util.ts"), @@ -812,7 +819,7 @@ mod tests { let importer = sub.join("main.ts"); std::fs::write(&importer, "// importer\n").unwrap(); let resolved = - expect_file(resolve_subpath_import("#util", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#util", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/util.ts"), "{resolved:?}"); } @@ -825,7 +832,7 @@ mod tests { let importer = root.join("src/main.ts"); std::fs::write(&importer, "// importer\n").unwrap(); assert_eq!( - resolve_subpath_import("#lib/foo", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#lib/foo", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } @@ -839,7 +846,7 @@ mod tests { &["src/lib/foo.ts"], ); assert_eq!( - resolve_subpath_import("#other", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#other", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } @@ -849,7 +856,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#blocked": null }"##, &[]); assert_eq!( - resolve_subpath_import("#blocked", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#blocked", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } @@ -867,7 +874,7 @@ mod tests { let importer = dep.join("index.ts"); std::fs::write(&importer, "// importer\n").unwrap(); assert_eq!( - resolve_subpath_import("#util", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#util", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index 1edbe85023..6de1ea8581 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1973,3 +1973,7 @@ mod ancestor_node_modules_tests { ); } } + +#[cfg(test)] +#[path = "tests/bun_export_condition_tests.rs"] +mod bun_export_condition_tests; diff --git a/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs b/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs new file mode 100644 index 0000000000..39f934aa7a --- /dev/null +++ b/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs @@ -0,0 +1,83 @@ +//! Issue #10281 regression: `--platform bun` export-condition selection. +//! +//! Split out of `resolve/tests.rs` to keep that file under the 2000-line +//! repository limit. + +/// Issue #10281 — `--platform bun` must select a package's `bun` export +/// condition. Perry's condition list had no `bun` entry and ranked `node` +/// first, so a package shipping both compiled its node build even when the +/// target was bun. `@opentui/core` is the shape that surfaced it: its two +/// entries load different renderer backends, so OpenCode's terminal interface +/// ran the wrong one and threw. +/// +/// The two directions live in ONE test on purpose: the platform is a +/// process-wide flag (a compile is one process with one target), so separate +/// `#[test]` functions would race under the default parallel test harness. +/// The flag is restored before returning. +use super::super::resolve_exports_candidates; +use super::super::subpath_imports::{default_conditions, set_bun_platform}; + +fn opentui_core_exports() -> serde_json::Value { + serde_json::json!({ + ".": { + "types": "./index.d.ts", + "bun": "./index.bun.js", + "node": "./index.node.js", + "import": "./index.node.js" + } + }) +} + +#[test] +fn bun_platform_prefers_the_bun_entry_and_node_target_is_unchanged() { + // Default (node target): the node entry wins, exactly as before. + set_bun_platform(false); + assert_eq!( + default_conditions().first().copied(), + Some("perry"), + "an explicit perry entry must keep winning on either target" + ); + assert!( + !default_conditions().contains(&"bun"), + "the node target must not consider the bun condition" + ); + let node_first = resolve_exports_candidates(&opentui_core_exports(), "."); + assert_eq!( + node_first.first().map(String::as_str), + Some("./index.node.js"), + "node target must resolve the node entry; got {node_first:?}" + ); + + // `--platform bun`: the bun entry wins, and `perry` still outranks it. + set_bun_platform(true); + let conditions = default_conditions(); + assert_eq!( + (conditions.first().copied(), conditions.get(1).copied()), + (Some("perry"), Some("bun")), + "bun must rank directly after perry, above node; got {conditions:?}" + ); + let bun_first = resolve_exports_candidates(&opentui_core_exports(), "."); + assert_eq!( + bun_first.first().map(String::as_str), + Some("./index.bun.js"), + "bun target must resolve the bun entry; got {bun_first:?}" + ); + // The node entry stays available as a fallback for the disk-existence + // walk in `resolve_package_entry`, it is only no longer first. + assert!( + bun_first.iter().any(|c| c == "./index.node.js"), + "the node entry must remain a candidate; got {bun_first:?}" + ); + + // A package with no `bun` entry is unaffected by the target. + let plain = serde_json::json!({ ".": { "node": "./n.js", "default": "./d.js" } }); + assert_eq!( + resolve_exports_candidates(&plain, ".") + .first() + .map(String::as_str), + Some("./n.js"), + "a package without a bun entry must resolve identically on either target" + ); + + set_bun_platform(false); +} From e7b7c6d67c2bc0912aaa69be74c24478f6dea199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 09:44:08 +0200 Subject: [PATCH 4/5] test(resolve): isolate the platform flag and compile both platforms via the CLI The bun condition unit test flips a process-wide flag that every other resolver test reads, so it could race them under the parallel harness. Run its body in a child test process and assert the child actually ran. Add an integration test that compiles one program with --platform node and --platform bun, covering the flag's wiring into the resolver: bun above node, perry above bun, a package without a bun entry, a missing bun file falling back to node, and a #imports conditional. Key both changelog fragments to their PRs (#10282, #10283). --- ...-exports.md => 10282-cjs-cycle-exports.md} | 0 ...ition.md => 10283-bun-export-condition.md} | 0 .../tests/bun_export_condition_tests.rs | 21 +++ .../issue_10281_platform_bun_conditions.rs | 131 ++++++++++++++++++ 4 files changed, 152 insertions(+) rename changelog.d/{10178-cjs-cycle-exports.md => 10282-cjs-cycle-exports.md} (100%) rename changelog.d/{10281-bun-export-condition.md => 10283-bun-export-condition.md} (100%) create mode 100644 crates/perry/tests/issue_10281_platform_bun_conditions.rs diff --git a/changelog.d/10178-cjs-cycle-exports.md b/changelog.d/10282-cjs-cycle-exports.md similarity index 100% rename from changelog.d/10178-cjs-cycle-exports.md rename to changelog.d/10282-cjs-cycle-exports.md diff --git a/changelog.d/10281-bun-export-condition.md b/changelog.d/10283-bun-export-condition.md similarity index 100% rename from changelog.d/10281-bun-export-condition.md rename to changelog.d/10283-bun-export-condition.md diff --git a/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs b/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs index 39f934aa7a..3f73dc1daf 100644 --- a/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs @@ -30,6 +30,27 @@ fn opentui_core_exports() -> serde_json::Value { #[test] fn bun_platform_prefers_the_bun_entry_and_node_target_is_unchanged() { + // Other resolver tests also consult this process-wide flag. Exercise the + // two targets in an isolated test process so parallel tests cannot observe + // the temporary Bun setting, and assert that the selected test ran. + const CHILD: &str = "PERRY_TEST_BUN_CONDITIONS_CHILD"; + if std::env::var_os(CHILD).is_none() { + let module = module_path!().split_once("::").unwrap().1; + let name = + format!("{module}::bun_platform_prefers_the_bun_entry_and_node_target_is_unchanged"); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &name, "--test-threads=1", "--nocapture"]) + .env(CHILD, "1") + .output() + .expect("run isolated Bun condition test"); + assert!( + child.status.success() && String::from_utf8_lossy(&child.stdout).contains("1 passed;"), + "isolated condition test failed or did not run\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + return; + } // Default (node target): the node entry wins, exactly as before. set_bun_platform(false); assert_eq!( diff --git a/crates/perry/tests/issue_10281_platform_bun_conditions.rs b/crates/perry/tests/issue_10281_platform_bun_conditions.rs new file mode 100644 index 0000000000..5470f8cc4b --- /dev/null +++ b/crates/perry/tests/issue_10281_platform_bun_conditions.rs @@ -0,0 +1,131 @@ +//! #10281: `--platform bun` ranks the `bun` condition directly below `perry` +//! for package `exports` and `#imports`; `--platform node` is unchanged. +//! +//! The resolver's unit test flips the platform flag directly. This compiles +//! through the CLI, so it also covers the flag reaching the resolver. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +/// A package whose `exports["."]` is `conditions`, shipping one module per +/// condition that exports its own name. +fn package(root: &Path, name: &str, conditions: &str) { + write( + root, + &format!("node_modules/{name}/package.json"), + &format!(r#"{{"name": "{name}", "type": "module", "exports": {{".": {conditions}}}}}"#), + ); + for entry in ["perry", "bun", "node", "default"] { + write( + root, + &format!("node_modules/{name}/{entry}.js"), + &format!("export default \"{name}-{entry}\";\n"), + ); + } +} + +fn compile_and_run(root: &Path, platform: &str) -> String { + let compiler = PathBuf::from(env!("CARGO_BIN_EXE_perry")); + let runtime = std::env::var_os("PERRY_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| compiler.parent().expect("compiler directory").to_path_buf()); + let output = root.join(format!("main_{platform}")); + let compile = Command::new(&compiler) + .current_dir(root) + .arg("compile") + .arg(root.join("main.mjs")) + .arg("--platform") + .arg(platform) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "--platform {platform} compile failed: {:?}\n{}\n{}", + compile.status, + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(root) + .output() + .expect("run compiled program"); + assert!( + run.status.success(), + "--platform {platform} program failed: {:?}\n{}\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).expect("UTF-8 output") +} + +#[test] +fn platform_flag_selects_the_bun_condition_only_for_bun() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "package.json", + r##"{"name": "bun-condition-probe", "type": "module", "imports": {"#mode": {"bun": "./bun.js", "node": "./node.js", "default": "./default.js"}}}"##, + ); + for entry in ["bun", "node", "default"] { + write( + root, + &format!("{entry}.js"), + &format!("export default \"imports-{entry}\";\n"), + ); + } + package( + root, + "both", + r#"{"bun": "./bun.js", "node": "./node.js", "default": "./default.js"}"#, + ); + // An explicit perry entry outranks bun on either target. + package( + root, + "perry-first", + r#"{"perry": "./perry.js", "bun": "./bun.js", "node": "./node.js"}"#, + ); + // A package without a bun entry resolves identically on either target. + package( + root, + "no-bun", + r#"{"node": "./node.js", "default": "./default.js"}"#, + ); + // A bun entry whose file is missing falls back to the node entry. + package( + root, + "fallback", + r#"{"bun": "./missing.js", "node": "./node.js"}"#, + ); + write( + root, + "main.mjs", + "import a from \"both\";\n\ + import b from \"perry-first\";\n\ + import c from \"no-bun\";\n\ + import d from \"fallback\";\n\ + import e from \"#mode\";\n\ + console.log(a, b, c, d, e);\n", + ); + + assert_eq!( + compile_and_run(root, "node"), + "both-node perry-first-perry no-bun-node fallback-node imports-node\n" + ); + assert_eq!( + compile_and_run(root, "bun"), + "both-bun perry-first-perry no-bun-node fallback-node imports-bun\n" + ); +} From fed8a753c3e8147f6009d8239d93c0f4b844be72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 09:44:36 +0200 Subject: [PATCH 5/5] chore: release merge train 195 as v0.5.1573 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b1bb8eb2a3..697f375c04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1572 +**Current Version:** 0.5.1573 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 3e72f191c2..fb2322da19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5583,7 +5583,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "base64 0.22.1", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-dispatch", "serde", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "cc", "libc", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "aho-corasick", "anyhow", @@ -5681,7 +5681,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-hir", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-hir", @@ -5697,7 +5697,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-dispatch", @@ -5706,7 +5706,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-hir", @@ -5714,7 +5714,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "base64 0.22.1", @@ -5726,7 +5726,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-hir", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "async-trait", "clap", @@ -5758,14 +5758,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "serde", "serde_json", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1572" +version = "0.5.1573" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "clap", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "block2", "objc2", @@ -5809,7 +5809,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "argon2", "perry-ffi", @@ -5818,7 +5818,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "reqwest", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "bcrypt", "perry-ffi", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "rusqlite", @@ -5843,7 +5843,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "scraper", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "perry-runtime", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "chrono", "cron", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "chrono", "perry-ffi", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "rust_decimal", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "serde_json", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5901,7 +5901,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "perry-runtime", @@ -5909,14 +5909,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "bytes", "http-body-util", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "bytes", "lazy_static", @@ -5946,7 +5946,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "bytes", @@ -5978,7 +5978,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "lazy_static", "perry-ffi", @@ -5988,7 +5988,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "lru", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "chrono", "perry-ffi", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "bson", "futures-util", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "chrono", "perry-ffi", @@ -6040,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "nanoid", "perry-ffi", @@ -6049,7 +6049,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "bytes", "perry-ffi", @@ -6064,7 +6064,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6083,7 +6083,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "lettre", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "notify", "perry-ffi", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "printpdf", @@ -6113,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "sqlx", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "perry-runtime", @@ -6131,7 +6131,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "governor", "perry-ffi", @@ -6139,7 +6139,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "fast_image_resize", "image", @@ -6150,7 +6150,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "lazy_static", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "perry-runtime", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "uuid", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-ffi", "perry-validation", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "futures-util", "lazy_static", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "brotli", "flate2", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6238,7 +6238,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-api-manifest", @@ -6258,11 +6258,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1572" +version = "0.5.1573" [[package]] name = "perry-parser" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "perry-diagnostics", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perex", "regex", @@ -6283,7 +6283,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "ahash", "base64 0.22.1", @@ -6341,14 +6341,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6437,21 +6437,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "dirs", "perry-ffi", @@ -6461,7 +6461,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "jni", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "rand 0.10.2", "serde", @@ -6486,7 +6486,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6509,7 +6509,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "block2", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "block2", @@ -6543,7 +6543,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1572" +version = "0.5.1573" [[package]] name = "perry-ui-test" @@ -6554,11 +6554,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1572" +version = "0.5.1573" [[package]] name = "perry-ui-tvos" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "block2", @@ -6575,7 +6575,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "block2", @@ -6592,7 +6592,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "block2", "libc", @@ -6606,7 +6606,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "libc", @@ -6625,7 +6625,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "base64 0.22.1", "libc", @@ -6638,7 +6638,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "anyhow", "base64 0.22.1", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "idna", "regex", @@ -6663,7 +6663,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1572" +version = "0.5.1573" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 3735806113..2d5530ab7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1572" +version = "0.5.1573" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"