@tishlang/tish-format 2.12.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/Cargo.toml +1 -1
  60. package/crates/tish_fmt/src/lib.rs +94 -1
  61. package/crates/tish_lexer/src/lib.rs +76 -0
  62. package/crates/tish_lexer/src/token.rs +4 -0
  63. package/crates/tish_lint/src/lib.rs +126 -0
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-fmt +0 -0
  85. package/platform/darwin-x64/tish-fmt +0 -0
  86. package/platform/linux-arm64/tish-fmt +0 -0
  87. package/platform/linux-x64/tish-fmt +0 -0
  88. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -10,6 +10,25 @@ mod types;
10
10
 
11
11
  pub use check::{check_program, TypeDiagnostic};
12
12
 
13
+ /// The native typed-codegen optimizations — numeric param inference, struct/aggregate inference,
14
+ /// native (monomorphic) free fns, native-vec params, recursive-struct arena lowering, fused/native
15
+ /// higher-order fns, and native `number[]` params — are **ON BY DEFAULT**. There are no per-pass
16
+ /// flags anymore: that per-flag gating caused repeated "did I set all of them?" drift between the
17
+ /// gauntlet, manual builds, and CI. The single escape hatch `TISH_NATIVE_OPT=0` turns the whole
18
+ /// stack off — used only by the gauntlet's boxed A/B baseline and to bisect a suspected miscompile.
19
+ pub(crate) fn native_opts_enabled() -> bool {
20
+ std::env::var("TISH_NATIVE_OPT").map(|v| v != "0").unwrap_or(true)
21
+ }
22
+
23
+ /// #381 — the native recursion guard: rotation copies for self/mutually-recursive typed fns plus a
24
+ /// depth guard at boxed user-fn closure entry, so unbounded recursion raises a catchable
25
+ /// `RangeError` instead of overflowing the stack (an uncatchable process abort). **Default ON**;
26
+ /// `TISH_NATIVE_RECUR_GUARD=0` turns it off — mirrors the VM JIT tier's `TISH_JIT_RECUR_GUARD`
27
+ /// (for bisection and code-size-sensitive builds; unbounded recursion then aborts again, as before).
28
+ pub(crate) fn native_recur_guard_enabled() -> bool {
29
+ std::env::var("TISH_NATIVE_RECUR_GUARD").map(|v| v != "0").unwrap_or(true)
30
+ }
31
+
13
32
  /// How generated Rust is linked (desktop binary vs embedded iOS staticlib).
14
33
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15
34
  pub enum NativeEmitMode {
@@ -88,8 +107,10 @@ fn count(): number {
88
107
 
89
108
  #[test]
90
109
  fn loop_var_decl_clone_outer_var() {
91
- // With inference, outerVar = 42 gets inferred as f64. f64 is Copy, so no clone is
92
- // needed — direct assignment is correct. The test verifies compilation succeeds.
110
+ // outerVar = 42 is inferred as f64. Per #313, a top-level numeric used ONLY at top level
111
+ // (never inside a function/closure body) is a native `run()`-local `let mut _: f64`, NOT a
112
+ // `thread_local Cell<f64>` — the Cell is reserved for globals a function reads across calls
113
+ // (e.g. fasta's `seed`). The read `let x = outerVar` loads it as a Copy f64.
93
114
  let src = r#"
94
115
  let outerVar = 42
95
116
  for (let i = 0; i < 5; i = i + 1) {
@@ -98,10 +119,14 @@ for (let i = 0; i < 5; i = i + 1) {
98
119
  "#;
99
120
  let program = parse(src).unwrap();
100
121
  let rust = compile(&program).unwrap();
101
- // outerVar and x are f64 (inferred) — Copy assignment, no .clone() needed.
122
+ // outerVar is a native f64 local (no TLS Cell, since no function references it); x reads it.
102
123
  assert!(
103
124
  rust.contains("let mut outerVar: f64"),
104
- "expected outerVar: f64"
125
+ "expected outerVar as a native f64 local (#313)"
126
+ );
127
+ assert!(
128
+ !rust.contains("G_OUTERVAR"),
129
+ "outerVar must NOT be a thread_local Cell global — it is top-level-only (#313)"
105
130
  );
106
131
  assert!(rust.contains("let mut x: f64"), "expected x: f64");
107
132
  }
@@ -49,17 +49,23 @@ pub struct NativeBuildArtifacts {
49
49
  pub native_init: std::collections::HashMap<String, NativeModuleInit>,
50
50
  }
51
51
 
52
- /// Node-compatible aliases for built-in modules (fs -> tish:fs, etc.).
52
+ /// Node-compatible aliases for built-in modules (fs -> tish:fs, etc.). The `node:` prefix
53
+ /// (e.g. `node:fs`, `node:fs/promises`) is stripped before lookup.
53
54
  const BUILTIN_ALIASES: &[(&str, &str)] = &[
54
55
  ("fs", "tish:fs"),
56
+ ("fs/promises", "tish:fs/promises"),
55
57
  ("http", "tish:http"),
56
58
  ("timers", "tish:timers"),
57
59
  ("process", "tish:process"),
58
60
  ("ws", "tish:ws"),
61
+ ("tty", "tish:tty"),
59
62
  ];
60
63
 
61
- /// Normalize built-in spec to canonical form. E.g. "fs" -> "tish:fs".
64
+ /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
65
+ /// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`) and bare aliases.
62
66
  pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
67
+ // Strip a leading `node:` so `node:fs` resolves the same as `fs`.
68
+ let spec = spec.strip_prefix("node:").unwrap_or(spec);
63
69
  if spec.starts_with("tish:") {
64
70
  return Some(spec.to_string());
65
71
  }
@@ -71,10 +77,20 @@ pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
71
77
 
72
78
  /// Built-in modules that come from tishlang_runtime, not from package.json.
73
79
  pub fn is_builtin_native_spec(spec: &str) -> bool {
80
+ let spec = spec.strip_prefix("node:").unwrap_or(spec);
74
81
  matches!(
75
82
  spec,
76
- "tish:fs" | "tish:http" | "tish:timers" | "tish:process" | "tish:ws" | "tish:tty"
77
- ) || matches!(spec, "fs" | "http" | "timers" | "process" | "ws" | "tty")
83
+ "tish:fs"
84
+ | "tish:fs/promises"
85
+ | "tish:http"
86
+ | "tish:timers"
87
+ | "tish:process"
88
+ | "tish:ws"
89
+ | "tish:tty"
90
+ ) || matches!(
91
+ spec,
92
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
93
+ )
78
94
  }
79
95
 
80
96
  /// Resolve all native imports in a merged program via package.json lookup.
@@ -238,9 +254,12 @@ pub fn program_uses_document(program: &Program) -> bool {
238
254
  || update.as_ref().is_some_and(expr_uses_document)
239
255
  || stmt_uses_document(body)
240
256
  }
241
- Statement::ForOf { iterable, body, .. } => {
242
- expr_uses_document(iterable) || stmt_uses_document(body)
243
- }
257
+ Statement::ForOf { iterable, body, .. }
258
+ | Statement::ForIn {
259
+ object: iterable,
260
+ body,
261
+ ..
262
+ } => expr_uses_document(iterable) || stmt_uses_document(body),
244
263
  Statement::Switch {
245
264
  expr,
246
265
  cases,
@@ -854,7 +873,7 @@ pub fn resolve_project_from_stdin(
854
873
  let from_dir = stdin_path.parent().unwrap_or_else(|| Path::new("."));
855
874
 
856
875
  for stmt in &program.statements {
857
- if let Statement::Import { from, .. } = stmt {
876
+ if let Some(from) = stmt_module_dep(stmt) {
858
877
  if is_native_import(from.as_ref()) {
859
878
  continue;
860
879
  }
@@ -883,6 +902,21 @@ pub fn resolve_project_from_stdin(
883
902
  .collect())
884
903
  }
885
904
 
905
+ /// #305 — the module path a statement depends on: an `import ... from "p"` or a re-export
906
+ /// `export ... from "p"`. Used by dependency discovery + cycle detection so a module reached ONLY
907
+ /// through a re-export is still loaded. None for non-module statements.
908
+ fn stmt_module_dep(stmt: &Statement) -> Option<&std::sync::Arc<str>> {
909
+ match stmt {
910
+ Statement::Import { from, .. } => Some(from),
911
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
912
+ // A local named export (`export { a }`, from=None) has no module dependency.
913
+ ExportDeclaration::ReExport { from, .. } => from.as_ref(),
914
+ _ => None,
915
+ },
916
+ _ => None,
917
+ }
918
+ }
919
+
886
920
  fn load_module_recursive(
887
921
  module_path: &Path,
888
922
  project_root: &Path,
@@ -907,7 +941,7 @@ fn load_module_recursive(
907
941
  // Collect imports and load dependencies first (skip native imports)
908
942
  let dir = canonical.parent().unwrap_or(Path::new("."));
909
943
  for stmt in &program.statements {
910
- if let Statement::Import { from, .. } = stmt {
944
+ if let Some(from) = stmt_module_dep(stmt) {
911
945
  if is_native_import(from.as_ref()) {
912
946
  continue; // Native imports don't load files
913
947
  }
@@ -937,10 +971,15 @@ fn load_module_recursive(
937
971
  ///
938
972
  /// Scoped npm packages (`@scope/pkg`) are merged as Tish source unless imported via `tish:…`.
939
973
  pub fn is_native_import(spec: &str) -> bool {
974
+ // A leading `node:` (node:fs, node:fs/promises) resolves the same as the bare form.
975
+ let spec = spec.strip_prefix("node:").unwrap_or(spec);
940
976
  spec.starts_with("tish:")
941
977
  || spec.starts_with("cargo:")
942
978
  || spec.starts_with("ffi:")
943
- || matches!(spec, "fs" | "http" | "timers" | "process" | "ws")
979
+ || matches!(
980
+ spec,
981
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
982
+ )
944
983
  }
945
984
 
946
985
  /// True for `ffi:…` specs (portable C-ABI cdylib extensions, loadable on every backend). The
@@ -1113,7 +1152,7 @@ fn has_cycle_from(
1113
1152
  visiting: &mut HashSet<usize>,
1114
1153
  ) -> Result<bool, String> {
1115
1154
  for stmt in &program.statements {
1116
- if let Statement::Import { from, .. } = stmt {
1155
+ if let Some(from) = stmt_module_dep(stmt) {
1117
1156
  if is_native_import(from.as_ref()) {
1118
1157
  continue;
1119
1158
  }
@@ -1146,6 +1185,11 @@ fn has_cycle_from(
1146
1185
  pub struct MergedProgram {
1147
1186
  pub program: Program,
1148
1187
  pub statement_sources: Vec<PathBuf>,
1188
+ /// #295: the ENTRY module's exported `(local_name, exported_name)` pairs, excluding `default`
1189
+ /// (handled separately as `export default`). Merge unwraps exports into plain top-level
1190
+ /// declarations, so `--target js --format bundle` re-emits these as a trailing `export { … }` to
1191
+ /// produce a valid ES module. Sorted by local name for deterministic output.
1192
+ pub entry_exports: Vec<(String, String)>,
1149
1193
  }
1150
1194
 
1151
1195
  fn merge_push(
@@ -1374,7 +1418,11 @@ fn shadow_params(
1374
1418
  }
1375
1419
 
1376
1420
  /// Scope-aware statement rewriter for [`isolate_private_top_level_bindings`].
1377
- fn rewrite_stmt_scope(
1421
+ /// Scope-aware statement rewriter: rename declared bindings and their free references
1422
+ /// according to `active` (name → new Arc). Exposed `pub(crate)` so the #179 factory inliner
1423
+ /// (infer.rs) can reuse this exhaustive, resolution-tested renamer for alpha-renaming a
1424
+ /// spliced factory body instead of hand-rolling a completeness-sensitive walker.
1425
+ pub(crate) fn rewrite_stmt_scope(
1378
1426
  stmt: &mut Statement,
1379
1427
  active: &mut HashMap<String, Arc<str>>,
1380
1428
  top_level: bool,
@@ -1447,6 +1495,12 @@ fn rewrite_stmt_scope(
1447
1495
  iterable,
1448
1496
  body,
1449
1497
  ..
1498
+ }
1499
+ | Statement::ForIn {
1500
+ name,
1501
+ object: iterable,
1502
+ body,
1503
+ ..
1450
1504
  } => {
1451
1505
  rewrite_expr_scope(iterable, active);
1452
1506
  let mut child = active.clone();
@@ -1522,6 +1576,9 @@ fn rewrite_stmt_scope(
1522
1576
  // body can still reference module-private names that were renamed.
1523
1577
  ExportDeclaration::Named(inner) => rewrite_stmt_scope(inner, active, top_level),
1524
1578
  ExportDeclaration::Default(e) => rewrite_expr_scope(e, active),
1579
+ // #305: a re-export has no inner statement/expr to scope-rewrite; names are resolved by
1580
+ // the export-table merge against the dep's (already-rewritten) bindings.
1581
+ ExportDeclaration::ReExport { .. } => {}
1525
1582
  },
1526
1583
  Statement::Import { .. }
1527
1584
  | Statement::TypeAlias { .. }
@@ -1533,7 +1590,8 @@ fn rewrite_stmt_scope(
1533
1590
  /// Scope-aware expression rewriter: rename every free reference to a name in `active`.
1534
1591
  /// Expressions never declare module-level bindings, so `active` is read-only here; arrow
1535
1592
  /// functions clone it for their own (shadowed) parameter scope.
1536
- fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1593
+ /// `pub(crate)` so the #179 factory inliner can rename a spliced return expression.
1594
+ pub(crate) fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1537
1595
  let rename = |name: &mut Arc<str>| {
1538
1596
  if let Some(renamed) = active.get(name.as_ref()) {
1539
1597
  *name = Arc::clone(renamed);
@@ -1674,6 +1732,14 @@ fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1674
1732
  /// Import statements are rewritten as bindings from already-emitted dep exports.
1675
1733
  /// Export statements are unwrapped (the inner declaration is emitted).
1676
1734
  pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram, String> {
1735
+ // #469: give the ENTRY module a Go/Rust/Swift-style `main` entry point. The entry is the last
1736
+ // module (post-order dependency load order), and this runs BEFORE private-binding isolation so the
1737
+ // synthetic `main()` call is renamed in lockstep with the `main` declaration if isolation renames
1738
+ // it. Every backend consumes the merged program, so this keeps interp/vm/native/js consistent.
1739
+ if let Some(entry) = modules.last_mut() {
1740
+ tishlang_ast::append_main_entry(&mut entry.program.statements);
1741
+ }
1742
+
1677
1743
  // #97: isolate module-private top-level bindings before they are flattened together.
1678
1744
  isolate_private_top_level_bindings(&mut modules);
1679
1745
 
@@ -1701,6 +1767,57 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1701
1767
  let default_name = format!("__default_{}", idx);
1702
1768
  module_exports[idx].insert("default".to_string(), default_name);
1703
1769
  }
1770
+ // #305: re-export — map each re-exported name to the DEP's binding (the dep is
1771
+ // earlier in load order, so its export table is already built). `export *` copies
1772
+ // every dep export (without overriding an explicit one); `export { a as b }` maps
1773
+ // b -> dep's binding for a. Downstream imports then resolve straight to the dep.
1774
+ ExportDeclaration::ReExport {
1775
+ specifiers,
1776
+ all,
1777
+ from: Some(from),
1778
+ ..
1779
+ } => {
1780
+ let dir = module.path.parent().unwrap_or_else(|| Path::new("."));
1781
+ let dep = resolve_import_path(from.as_ref(), dir, Path::new("."))
1782
+ .ok()
1783
+ .map(|p| p.canonicalize().unwrap_or(p))
1784
+ .and_then(|p| path_to_idx.get(&p).copied())
1785
+ .map(|dep_idx| module_exports[dep_idx].clone());
1786
+ if let Some(dep) = dep {
1787
+ if *all {
1788
+ for (k, v) in &dep {
1789
+ module_exports[idx]
1790
+ .entry(k.clone())
1791
+ .or_insert_with(|| v.clone());
1792
+ }
1793
+ }
1794
+ for spec in specifiers {
1795
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1796
+ if let Some(binding) = dep.get(name.as_ref()) {
1797
+ let export_name =
1798
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
1799
+ module_exports[idx].insert(export_name, binding.clone());
1800
+ }
1801
+ }
1802
+ }
1803
+ }
1804
+ }
1805
+ // #415 local named export (`export { a, b as c }`, no `from`): each specifier
1806
+ // exports an already-declared LOCAL top-level binding, mapping export-name -> that
1807
+ // binding. (`isolate_private_top_level_bindings` keeps exported names un-renamed.)
1808
+ ExportDeclaration::ReExport {
1809
+ specifiers,
1810
+ from: None,
1811
+ ..
1812
+ } => {
1813
+ for spec in specifiers {
1814
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1815
+ let export_name =
1816
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
1817
+ module_exports[idx].insert(export_name, name.to_string());
1818
+ }
1819
+ }
1820
+ }
1704
1821
  }
1705
1822
  }
1706
1823
  }
@@ -1885,6 +2002,10 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1885
2002
  src_path.clone(),
1886
2003
  );
1887
2004
  }
2005
+ // #305: re-export emits no code — `module_exports` already maps the re-exported
2006
+ // names to the dep's bindings (in scope in the flattened bundle), so downstream
2007
+ // imports resolve directly. Nothing to push here.
2008
+ ExportDeclaration::ReExport { .. } => {}
1888
2009
  },
1889
2010
  _ => merge_push(
1890
2011
  &mut statements,
@@ -1895,9 +2016,26 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1895
2016
  }
1896
2017
  }
1897
2018
  }
2019
+ // #295: capture the ENTRY module's exports (the entry is last — deps are emitted first). Merge
2020
+ // already built `module_exports[idx]` as exported_name -> local_binding; re-express as
2021
+ // (local, exported) for the JS bundle emitter, excluding `default` (emitted as `export default`).
2022
+ let entry_exports: Vec<(String, String)> = modules
2023
+ .len()
2024
+ .checked_sub(1)
2025
+ .map(|last| {
2026
+ let mut v: Vec<(String, String)> = module_exports[last]
2027
+ .iter()
2028
+ .filter(|(exported, _)| exported.as_str() != "default")
2029
+ .map(|(exported, local)| (local.clone(), exported.clone()))
2030
+ .collect();
2031
+ v.sort();
2032
+ v
2033
+ })
2034
+ .unwrap_or_default();
1898
2035
  Ok(MergedProgram {
1899
2036
  program: Program { statements },
1900
2037
  statement_sources,
2038
+ entry_exports,
1901
2039
  })
1902
2040
  }
1903
2041