@tishlang/tish 1.13.1 → 2.0.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 (106) hide show
  1. package/Cargo.toml +2 -0
  2. package/bin/tish +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +1 -0
  4. package/crates/tish/Cargo.toml +11 -3
  5. package/crates/tish/build.rs +21 -0
  6. package/crates/tish/src/cli_help.rs +15 -4
  7. package/crates/tish/src/main.rs +93 -21
  8. package/crates/tish/src/repl_completion.rs +0 -1
  9. package/crates/tish/tests/error_source_location.rs +36 -0
  10. package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
  11. package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
  12. package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
  13. package/crates/tish/tests/integration_test.rs +402 -91
  14. package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
  15. package/crates/tish/tests/tty_capability.rs +43 -0
  16. package/crates/tish_ast/src/ast.rs +37 -8
  17. package/crates/tish_builtins/Cargo.toml +2 -0
  18. package/crates/tish_builtins/src/array.rs +375 -13
  19. package/crates/tish_builtins/src/collections.rs +481 -0
  20. package/crates/tish_builtins/src/construct.rs +59 -19
  21. package/crates/tish_builtins/src/date.rs +538 -0
  22. package/crates/tish_builtins/src/globals.rs +86 -6
  23. package/crates/tish_builtins/src/iterator.rs +129 -0
  24. package/crates/tish_builtins/src/lib.rs +5 -0
  25. package/crates/tish_builtins/src/number.rs +96 -0
  26. package/crates/tish_builtins/src/object.rs +2 -2
  27. package/crates/tish_builtins/src/string.rs +19 -20
  28. package/crates/tish_builtins/src/symbol.rs +1 -1
  29. package/crates/tish_builtins/src/typedarrays.rs +298 -0
  30. package/crates/tish_bytecode/src/chunk.rs +69 -1
  31. package/crates/tish_bytecode/src/compiler.rs +933 -89
  32. package/crates/tish_bytecode/src/encoding.rs +2 -0
  33. package/crates/tish_bytecode/src/lib.rs +2 -1
  34. package/crates/tish_bytecode/src/opcode.rs +47 -4
  35. package/crates/tish_bytecode/src/serialize.rs +31 -1
  36. package/crates/tish_compile/Cargo.toml +1 -0
  37. package/crates/tish_compile/src/check.rs +774 -0
  38. package/crates/tish_compile/src/codegen.rs +2334 -349
  39. package/crates/tish_compile/src/infer.rs +1395 -6
  40. package/crates/tish_compile/src/lib.rs +50 -8
  41. package/crates/tish_compile/src/resolve.rs +584 -21
  42. package/crates/tish_compile/src/types.rs +106 -2
  43. package/crates/tish_compile_js/src/codegen.rs +67 -0
  44. package/crates/tish_compile_js/src/tests_jsx.rs +64 -0
  45. package/crates/tish_core/Cargo.toml +7 -1
  46. package/crates/tish_core/src/console_style.rs +11 -1
  47. package/crates/tish_core/src/json.rs +81 -38
  48. package/crates/tish_core/src/lib.rs +3 -0
  49. package/crates/tish_core/src/shape.rs +85 -0
  50. package/crates/tish_core/src/value.rs +679 -25
  51. package/crates/tish_core/src/vmref.rs +13 -8
  52. package/crates/tish_cranelift/src/link.rs +17 -4
  53. package/crates/tish_cranelift_runtime/Cargo.toml +1 -0
  54. package/crates/tish_eval/Cargo.toml +6 -0
  55. package/crates/tish_eval/src/eval.rs +665 -117
  56. package/crates/tish_eval/src/http.rs +4 -1
  57. package/crates/tish_eval/src/natives.rs +165 -13
  58. package/crates/tish_eval/src/value.rs +31 -13
  59. package/crates/tish_eval/src/value_convert.rs +10 -4
  60. package/crates/tish_ffi/Cargo.toml +26 -0
  61. package/crates/tish_ffi/src/lib.rs +518 -0
  62. package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
  63. package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
  64. package/crates/tish_ffi/tests/loader.rs +65 -0
  65. package/crates/tish_fmt/src/lib.rs +43 -5
  66. package/crates/tish_lexer/src/lib.rs +397 -9
  67. package/crates/tish_lexer/src/token.rs +7 -0
  68. package/crates/tish_lint/src/lib.rs +2 -10
  69. package/crates/tish_lsp/src/import_goto.rs +2 -0
  70. package/crates/tish_lsp/src/main.rs +439 -26
  71. package/crates/tish_native/src/build.rs +55 -1
  72. package/crates/tish_opt/src/lib.rs +126 -23
  73. package/crates/tish_parser/src/lib.rs +55 -1
  74. package/crates/tish_parser/src/parser.rs +456 -34
  75. package/crates/tish_pg/src/lib.rs +3 -3
  76. package/crates/tish_resolve/src/lib.rs +99 -59
  77. package/crates/tish_runtime/Cargo.toml +4 -0
  78. package/crates/tish_runtime/src/http.rs +66 -17
  79. package/crates/tish_runtime/src/http_fetch.rs +29 -8
  80. package/crates/tish_runtime/src/http_hyper.rs +25 -2
  81. package/crates/tish_runtime/src/lib.rs +299 -44
  82. package/crates/tish_runtime/src/promise.rs +328 -18
  83. package/crates/tish_runtime/src/timers.rs +13 -7
  84. package/crates/tish_runtime/src/tty.rs +226 -0
  85. package/crates/tish_runtime/src/ws.rs +35 -18
  86. package/crates/tish_runtime/tests/fetch_readable_stream.rs +2 -2
  87. package/crates/tish_ui/src/jsx.rs +10 -0
  88. package/crates/tish_ui/src/runtime/hooks.rs +19 -15
  89. package/crates/tish_ui/src/runtime/mod.rs +15 -12
  90. package/crates/tish_vm/Cargo.toml +14 -1
  91. package/crates/tish_vm/src/jit.rs +1050 -0
  92. package/crates/tish_vm/src/lib.rs +2 -0
  93. package/crates/tish_vm/src/vm.rs +1546 -202
  94. package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
  95. package/crates/tish_wasm/src/lib.rs +6 -2
  96. package/crates/tish_wasm_runtime/src/gpu.rs +17 -1
  97. package/crates/tishlang_cargo_bindgen/src/classify.rs +1 -3
  98. package/crates/tishlang_cargo_bindgen/src/lib.rs +2 -2
  99. package/crates/tishlang_cargo_bindgen/src/metadata.rs +1 -1
  100. package/justfile +8 -0
  101. package/package.json +1 -1
  102. package/platform/darwin-arm64/tish +0 -0
  103. package/platform/darwin-x64/tish +0 -0
  104. package/platform/linux-arm64/tish +0 -0
  105. package/platform/linux-x64/tish +0 -0
  106. package/platform/win32-x64/tish.exe +0 -0
@@ -86,6 +86,12 @@ fn combined_mvp_native_inputs_hash(paths: &[PathBuf]) -> u64 {
86
86
  if value_rs.is_file() {
87
87
  file_content_hash(&value_rs).hash(&mut h);
88
88
  }
89
+ // Inference (struct/param/return typing — M1/M4/M5) also drives native emission, so the
90
+ // native batch cache must invalidate when it changes too.
91
+ let infer_rs = workspace_root().join("crates/tish_compile/src/infer.rs");
92
+ if infer_rs.is_file() {
93
+ file_content_hash(&infer_rs).hash(&mut h);
94
+ }
89
95
  h.finish()
90
96
  }
91
97
 
@@ -142,7 +148,22 @@ fn file_content_hash(path: &Path) -> u64 {
142
148
  /// executing a binary still being written).
143
149
  fn compile_cached(bin: &Path, path: &Path, backend: &str) -> PathBuf {
144
150
  let stem = path.file_stem().unwrap().to_string_lossy();
145
- let hash = file_content_hash(path);
151
+ // The cache must invalidate when the COMPILER changes, not only the `.tish` source: cranelift/wasi
152
+ // (and native) bake the VM/codegen/runtime into the artifact, so a stale cached binary built before
153
+ // a VM fix would silently use old behaviour. The `tish` binary's mtime is the precise signal — any
154
+ // VM/compiler/runtime/codegen source edit rebuilds it. Mix it into the key alongside the file hash.
155
+ let bin_stamp = std::fs::metadata(bin)
156
+ .and_then(|m| m.modified())
157
+ .ok()
158
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
159
+ .map(|d| d.as_nanos() as u64)
160
+ .unwrap_or(0);
161
+ let hash = {
162
+ let mut h = DefaultHasher::new();
163
+ file_content_hash(path).hash(&mut h);
164
+ bin_stamp.hash(&mut h);
165
+ h.finish()
166
+ };
146
167
  let hash8 = &format!("{:016x}", hash)[..8];
147
168
  let cache_base = integration_compile_cache_dir().join(backend);
148
169
  let _ = std::fs::create_dir_all(&cache_base);
@@ -473,31 +494,185 @@ fn test_async_await_run() {
473
494
  }
474
495
  }
475
496
 
476
- /// Run Promise and setTimeout module tests (`promise` needs `http`; `settimeout` needs `timers`, which `http` enables).
477
- /// Ignored: tishlang_eval::run() does not run the event loop.
497
+ /// `promise.tish` full Promise API including `new Promise(executor)`, `.then`, `.catch`,
498
+ /// `all`, `race`, `any`, `allSettled`. Runs via the binary (vm + interp), asserts exact output.
499
+ /// This was previously `#[ignore]`'d and only checked "doesn't error" — the old fixture also
500
+ /// avoided `new Promise` entirely, hiding a bug where the executor never ran on the VM. Now
501
+ /// CI-gated and output-asserting. vm ≡ interp.
478
502
  #[test]
479
- #[cfg(feature = "http")]
480
- #[ignore = "requires async runtime"]
481
- fn test_promise_and_settimeout() {
482
- for name in ["promise", "settimeout"] {
483
- let path = workspace_root()
484
- .join("tests")
485
- .join("modules")
486
- .join(format!("{}.tish", name));
487
- if path.exists() {
488
- let source = std::fs::read_to_string(&path).unwrap();
489
- let result = tishlang_eval::run(&source);
490
- assert!(
491
- result.is_ok(),
492
- "Failed to run {}: {:?}",
493
- path.display(),
494
- result.err()
495
- );
496
- }
503
+ fn test_promise_core() {
504
+ let bin = tish_bin();
505
+ if !bin.exists() {
506
+ return;
507
+ }
508
+ let path = workspace_root()
509
+ .join("tests")
510
+ .join("modules")
511
+ .join("promise.tish");
512
+ if !path.exists() {
513
+ return;
514
+ }
515
+ let expected = "\
516
+ new Promise: new-ctor
517
+ Promise sync resolve: 42
518
+ Promise.resolve: 100
519
+ Promise.reject caught: true
520
+ .then chain: 4
521
+ .catch: handled: fail
522
+ Promise.all: 1 2 3
523
+ Promise.race: fast
524
+ Promise.any: any-win
525
+ Promise.allSettled: fulfilled rejected reason
526
+ Promise tests completed
527
+ ";
528
+ for backend_args in [vec!["run"], vec!["run", "--backend", "interp"]] {
529
+ let mut args = backend_args.clone();
530
+ args.push(path.to_string_lossy().to_string().leak());
531
+ let out = Command::new(&bin)
532
+ .args(&args)
533
+ .current_dir(workspace_root())
534
+ .output()
535
+ .expect("run tish binary");
536
+ assert!(
537
+ out.status.success(),
538
+ "promise.tish ({:?}) failed: stderr={}",
539
+ backend_args,
540
+ String::from_utf8_lossy(&out.stderr)
541
+ );
542
+ assert_eq!(
543
+ String::from_utf8_lossy(&out.stdout),
544
+ expected,
545
+ "Promise output mismatch on backend {:?} — check new Promise/any/allSettled regressions",
546
+ backend_args
547
+ );
548
+ }
549
+ }
550
+
551
+ /// Import aliasing — `{ x as y }` rename and `* as M` namespace. `as` is a dedicated keyword
552
+ /// token (shared with type casts), so the import-specifier parser must accept it in that position.
553
+ /// Regression: previously `import { a as b }` failed with "Expected Comma, got As". vm ≡ interp.
554
+ #[test]
555
+ fn test_import_alias() {
556
+ let bin = tish_bin();
557
+ if !bin.exists() {
558
+ return;
559
+ }
560
+ let path = workspace_root()
561
+ .join("tests")
562
+ .join("modules")
563
+ .join("import_alias.tish");
564
+ if !path.exists() {
565
+ return;
566
+ }
567
+ let expected = "42\nhi there\n42\n1.0\n";
568
+ for backend_args in [vec!["run"], vec!["run", "--backend", "vm"]] {
569
+ let mut args = backend_args.clone();
570
+ args.push(path.to_string_lossy().to_string().leak());
571
+ let out = Command::new(&bin)
572
+ .args(&args)
573
+ .current_dir(workspace_root())
574
+ .output()
575
+ .expect("run tish binary");
576
+ assert!(
577
+ out.status.success(),
578
+ "import_alias.tish ({:?}) failed: stderr={}",
579
+ backend_args,
580
+ String::from_utf8_lossy(&out.stderr)
581
+ );
582
+ assert_eq!(
583
+ String::from_utf8_lossy(&out.stdout),
584
+ expected,
585
+ "import alias output mismatch on backend {:?}",
586
+ backend_args
587
+ );
497
588
  }
498
589
  }
499
590
 
500
591
  /// Combined validation: async/await + Promise + setTimeout + multiple HTTP requests.
592
+ /// #97: a module's non-exported top-level bindings stay private — a same-named binding in
593
+ /// another module must not overwrite them (runtime), and the `--target js` bundle must not
594
+ /// emit duplicate `let` declarations (SyntaxError). Verified identical across interp / VM /
595
+ /// native / node, including parameter and inner-`let` shadowing.
596
+ #[test]
597
+ fn test_module_private_binding_isolation() {
598
+ let bin = tish_bin();
599
+ if !bin.exists() {
600
+ return;
601
+ }
602
+ let path = workspace_root()
603
+ .join("tests")
604
+ .join("modules")
605
+ .join("private_isolation.tish");
606
+ if !path.exists() {
607
+ return;
608
+ }
609
+ let expected = "from-a:helper-a from-b:helper-b\narg inner\nhelper-b\n";
610
+
611
+ // Interpreter + VM via `tish run`.
612
+ for backend in ["interp", "vm"] {
613
+ let out = Command::new(&bin)
614
+ .args(["run", "--backend", backend])
615
+ .arg(&path)
616
+ .current_dir(workspace_root())
617
+ .output()
618
+ .expect("run tish binary");
619
+ assert!(
620
+ out.status.success(),
621
+ "private_isolation.tish ({backend}) failed: stderr={}",
622
+ String::from_utf8_lossy(&out.stderr)
623
+ );
624
+ assert_eq!(
625
+ String::from_utf8_lossy(&out.stdout),
626
+ expected,
627
+ "module private-binding isolation mismatch on backend {backend}"
628
+ );
629
+ }
630
+
631
+ // Native: compile + run.
632
+ let native_bin = compile_cached(&bin, &path, "native");
633
+ let out = Command::new(&native_bin)
634
+ .current_dir(workspace_root())
635
+ .output()
636
+ .expect("run native binary");
637
+ let _ = std::fs::remove_file(&native_bin);
638
+ assert!(
639
+ out.status.success(),
640
+ "private_isolation native run failed: stderr={}",
641
+ String::from_utf8_lossy(&out.stderr)
642
+ );
643
+ assert_eq!(
644
+ String::from_utf8_lossy(&out.stdout),
645
+ expected,
646
+ "module private-binding isolation mismatch on native backend"
647
+ );
648
+
649
+ // JS target: compile + run through Node (also asserts no duplicate-`let` SyntaxError).
650
+ let node_available = Command::new("node")
651
+ .arg("--version")
652
+ .output()
653
+ .map(|o| o.status.success())
654
+ .unwrap_or(false);
655
+ if node_available {
656
+ let out_js = compile_cached(&bin, &path, "js");
657
+ let out = Command::new("node")
658
+ .arg(&out_js)
659
+ .current_dir(workspace_root())
660
+ .output()
661
+ .expect("run node");
662
+ let _ = std::fs::remove_file(&out_js);
663
+ assert!(
664
+ out.status.success(),
665
+ "private_isolation JS run failed: stderr={}",
666
+ String::from_utf8_lossy(&out.stderr)
667
+ );
668
+ assert_eq!(
669
+ String::from_utf8_lossy(&out.stdout),
670
+ expected,
671
+ "module private-binding isolation mismatch on JS target"
672
+ );
673
+ }
674
+ }
675
+
501
676
  /// Ignored: tishlang_eval::run() does not run the event loop.
502
677
  #[test]
503
678
  #[cfg(feature = "http")]
@@ -558,6 +733,109 @@ fn test_vm_date_now() {
558
733
  }
559
734
  }
560
735
 
736
+ /// `Promise.any`, `Promise.allSettled`, fixed `Promise.race` — cross-backend, network-free, CI-gated.
737
+ /// vm ≡ interp exact match.
738
+ #[test]
739
+ fn test_promise_combinators() {
740
+ let bin = tish_bin();
741
+ if !bin.exists() {
742
+ return;
743
+ }
744
+ let path = workspace_root()
745
+ .join("tests")
746
+ .join("modules")
747
+ .join("promise_combinators.tish");
748
+ if !path.exists() {
749
+ return;
750
+ }
751
+ let expected = "\
752
+ any first-fulfilled: win
753
+ any all-rejected: [\"e1\",\"e2\"]
754
+ allSettled[0] ok: 10
755
+ allSettled[1] rejected: boom
756
+ allSettled[2] ok: 30
757
+ race winner: A
758
+ any passthrough: 42
759
+ ";
760
+ for backend_args in [vec!["run"], vec!["run", "--backend", "interp"]] {
761
+ let mut args = backend_args.clone();
762
+ args.push(path.to_string_lossy().to_string().leak());
763
+ let out = Command::new(&bin)
764
+ .args(&args)
765
+ .current_dir(workspace_root())
766
+ .output()
767
+ .expect("run tish binary");
768
+ assert!(
769
+ out.status.success(),
770
+ "promise_combinators ({:?}) failed: stderr={}",
771
+ backend_args,
772
+ String::from_utf8_lossy(&out.stderr)
773
+ );
774
+ assert_eq!(
775
+ String::from_utf8_lossy(&out.stdout),
776
+ expected,
777
+ "Promise.any/allSettled/race divergence on backend {:?}",
778
+ backend_args
779
+ );
780
+ }
781
+ }
782
+
783
+ /// Pins tish's DOCUMENTED async/Promise ordering (docs/concurrency-model.md) — network-free, so it
784
+ /// runs in CI (unlike the `#[ignore]`'d network async tests). Asserts Promise.all order + non-promise
785
+ /// passthrough, `.then` chaining, await-reject catch, Promise.all reject short-circuit, AND the
786
+ /// deliberate blocking signature: a 0ms `setTimeout` queued before an `await` fires LAST (a JS event
787
+ /// loop would interleave it earlier — tish's `await` blocks instead of yielding). NOT compared to node
788
+ /// on purpose; this is tish's own contract. vm ≡ interp guards cross-backend agreement.
789
+ #[test]
790
+ fn test_async_ordering_documented() {
791
+ let bin = tish_bin();
792
+ if !bin.exists() {
793
+ return;
794
+ }
795
+ let path = workspace_root()
796
+ .join("tests")
797
+ .join("modules")
798
+ .join("async_ordering.tish");
799
+ if !path.exists() {
800
+ return;
801
+ }
802
+ // tish's documented, deliberately-non-JS ordering (timer drains last because await blocks).
803
+ let expected = "\
804
+ 1: sync-start
805
+ 2: await = 42
806
+ 2b: new Promise = ctor-ran
807
+ 3: all = a b c
808
+ 4: chain = 13
809
+ 5: caught = boom
810
+ 6: all-reject = rej
811
+ 7: post-await = after-timer-was-queued
812
+ 9: sync-end
813
+ 8: timer-fires-LAST (await did not yield)
814
+ ";
815
+ // Both the bytecode VM and the tree-walking interpreter must agree on this contract.
816
+ for backend_args in [vec!["run"], vec!["run", "--backend", "interp"]] {
817
+ let mut args = backend_args.clone();
818
+ args.push(path.to_string_lossy().to_string().leak());
819
+ let out = Command::new(&bin)
820
+ .args(&args)
821
+ .current_dir(workspace_root())
822
+ .output()
823
+ .expect("run tish binary");
824
+ assert!(
825
+ out.status.success(),
826
+ "async_ordering ({:?}) failed: stderr={}",
827
+ backend_args,
828
+ String::from_utf8_lossy(&out.stderr)
829
+ );
830
+ assert_eq!(
831
+ String::from_utf8_lossy(&out.stdout),
832
+ expected,
833
+ "async ordering divergence on backend {:?} — the documented blocking-await/timer contract changed",
834
+ backend_args
835
+ );
836
+ }
837
+ }
838
+
561
839
  /// VM run with parse+compile only (no resolve/merge) - isolates bytecode IndexAssign.
562
840
  #[test]
563
841
  fn test_vm_index_assign_direct() {
@@ -637,51 +915,92 @@ fn test_full_stack_parse() {
637
915
  }
638
916
  }
639
917
 
640
- /// Shared list of MVP test files used for static comparison (interpreter and native).
641
- const MVP_TEST_FILES: &[&str] = &[
642
- "nested_loops.tish",
643
- "scopes.tish",
644
- "optional_braces.tish",
645
- "optional_braces_braced.tish",
646
- "tab_indent.tish",
647
- "space_indent.tish",
648
- "fn_any.tish",
649
- "strict_equality.tish",
650
- "arrays.tish",
651
- "break_continue.tish",
652
- "length.tish",
653
- "objects.tish",
654
- "symbol.tish",
655
- "conditional.tish",
656
- "switch.tish",
657
- "do_while.tish",
658
- "typeof.tish",
659
- "inc_dec.tish",
660
- "try_catch.tish",
661
- "builtins.tish",
662
- "exponentiation.tish",
663
- "for_of.tish",
664
- "bitwise.tish",
665
- "math.tish",
666
- "optional_chaining.tish",
667
- "void.tish",
668
- "rest_params.tish",
669
- "json.tish",
670
- "uri.tish",
671
- "in_op.tish",
672
- "arrow_functions.tish",
673
- "template_literals.tish",
674
- "compound_assign.tish",
675
- "mutation.tish",
676
- "string_methods.tish",
677
- "array_methods.tish",
678
- "object_methods.tish",
679
- "types.tish",
680
- "logical_assign.tish",
681
- "spread.tish",
682
- "fn_param_destructuring.tish",
918
+ // (The hand-maintained `MVP_TEST_FILES` allowlist was removed in favor of `discover_core_tests()`
919
+ // below every `tests/core/*.tish` with a `.expected` now gets cross-backend coverage automatically.)
920
+
921
+ /// Tests whose `.expected` embeds elapsed-ms timings (perf/stress/probe) — nondeterministic, so
922
+ /// excluded from exact-output comparison. Run them via `just perf-*` instead.
923
+ const TIMING_NONDETERMINISTIC: &[&str] = &[
924
+ "array_stress.tish",
925
+ "array_stress_01_large_array_creation.tish",
926
+ "array_stress_02_iteration.tish",
927
+ "array_stress_03_map_filter_reduce.tish",
928
+ "array_stress_04_chained.tish",
929
+ "array_stress_05_sorting.tish",
930
+ "array_stress_06_search.tish",
931
+ "array_stress_07_splice_slice.tish",
932
+ "array_stress_08_concat_spread.tish",
933
+ "array_stress_09_flat.tish",
934
+ "array_stress_10_objects.tish",
935
+ "basic_types.tish",
936
+ "benchmark_granular.tish",
937
+ "new_features_perf.tish",
938
+ "object_stress.tish",
939
+ "objects_perf.tish",
940
+ "string_methods_perf.tish",
941
+ "recursion_stress.tish",
942
+ "jit_probe.tish",
683
943
  ];
684
944
 
945
+ /// Known cross-backend gaps skipped on the interp↔vm parity check, with reason + a tracking note.
946
+ /// Each is a real divergence to fix, not a permanent exclusion.
947
+ ///
948
+ /// Empty: the former `nested_complex.tish` gap (the VM's fixed 2-level `enclosing` lost captures
949
+ /// >2 levels deep — `level4` couldn't see `level1`'s `a`) is fixed. The VM now captures the full
950
+ /// lexical chain (`Vm.enclosing: Vec<ScopeMap>`), so closures nested arbitrarily deep resolve
951
+ /// every ancestor's locals. interp↔vm parity holds for all discovered tests.
952
+ const VM_PARITY_SKIP: &[&str] = &[];
953
+
954
+ /// Discover every `tests/core/*.tish` that has a `.expected` sibling, minus timing-nondeterministic
955
+ /// ones. Replaces the hand-maintained `MVP_TEST_FILES` allowlist so a new `*.tish` + `*.expected`
956
+ /// gets cross-backend coverage automatically (the allowlist silently left ~38 tests running nowhere).
957
+ fn discover_core_tests() -> Vec<String> {
958
+ let mut v: Vec<String> = std::fs::read_dir(core_dir())
959
+ .expect("read tests/core")
960
+ .filter_map(|e| {
961
+ let p = e.ok()?.path();
962
+ if p.extension().map(|x| x == "tish").unwrap_or(false) && expected_path(&p).exists() {
963
+ Some(p.file_name()?.to_string_lossy().into_owned())
964
+ } else {
965
+ None
966
+ }
967
+ })
968
+ .filter(|n| !TIMING_NONDETERMINISTIC.contains(&n.as_str()))
969
+ .collect();
970
+ v.sort();
971
+ v
972
+ }
973
+
974
+ /// True if `name` has a sibling `.js` (so it can run through the Node oracle).
975
+ fn has_js_sibling(name: &str) -> bool {
976
+ core_dir()
977
+ .join(name)
978
+ .with_extension("js")
979
+ .exists()
980
+ }
981
+
982
+ /// The gradual type checker must produce ZERO diagnostics on the (valid) corpus — any diagnostic is
983
+ /// a false positive. Lists every offender so they can be inspected/fixed.
984
+ #[test]
985
+ fn checker_no_false_positives_on_corpus() {
986
+ let mut flagged: Vec<String> = Vec::new();
987
+ for name in discover_core_tests() {
988
+ let src = std::fs::read_to_string(core_dir().join(&name)).unwrap();
989
+ if let Ok(prog) = tishlang_parser::parse(&src) {
990
+ let diags = tishlang_compile::check_program(&prog);
991
+ if !diags.is_empty() {
992
+ let msgs: Vec<String> = diags.iter().map(|d| d.message.clone()).collect();
993
+ flagged.push(format!("{name}: {}", msgs.join(" | ")));
994
+ }
995
+ }
996
+ }
997
+ assert!(
998
+ flagged.is_empty(),
999
+ "type checker flagged valid corpus programs (false positives):\n{}",
1000
+ flagged.join("\n")
1001
+ );
1002
+ }
1003
+
685
1004
  /// Run each .tish file with interpreter and compare stdout to static expected.
686
1005
  /// Set REGENERATE_EXPECTED=1 to write .expected files from interpreter output (run once, then commit).
687
1006
  #[test]
@@ -694,7 +1013,7 @@ fn test_mvp_programs_interpreter() {
694
1013
  bin.display()
695
1014
  );
696
1015
  let regenerate = std::env::var("REGENERATE_EXPECTED").as_deref() == Ok("1");
697
- for name in MVP_TEST_FILES {
1016
+ for name in &discover_core_tests() {
698
1017
  let path = core_dir.join(name);
699
1018
  if !path.exists() {
700
1019
  continue;
@@ -741,7 +1060,10 @@ fn test_mvp_programs_interp_vm_stdout_parity() {
741
1060
  "tish binary not found at {}. Run `cargo build -p tishlang` first.",
742
1061
  bin.display()
743
1062
  );
744
- for name in MVP_TEST_FILES {
1063
+ for name in &discover_core_tests() {
1064
+ if VM_PARITY_SKIP.contains(&name.as_str()) {
1065
+ continue;
1066
+ }
745
1067
  let path = core_dir.join(name);
746
1068
  if !path.exists() {
747
1069
  continue;
@@ -792,7 +1114,7 @@ fn test_mvp_programs_native() {
792
1114
  bin.display()
793
1115
  );
794
1116
 
795
- let mut paths: Vec<PathBuf> = MVP_TEST_FILES
1117
+ let mut paths: Vec<PathBuf> = discover_core_tests()
796
1118
  .iter()
797
1119
  .filter_map(|name| {
798
1120
  let p = core_dir.join(name);
@@ -899,26 +1221,14 @@ fn test_mvp_programs_native() {
899
1221
  assert!(errors.is_empty(), "native failures:\n{}", errors.join("\n"));
900
1222
  }
901
1223
 
902
- /// Curated list: files that pass with Cranelift (some constructs cause stack-underflow; see docs/builtins-gap-analysis.md).
903
- const CRANELIFT_TEST_FILES: &[&str] = &[
904
- "fn_any.tish",
905
- "strict_equality.tish",
906
- "switch.tish",
907
- "do_while.tish",
908
- "typeof.tish",
909
- "try_catch.tish",
910
- "json.tish",
911
- "math.tish",
912
- "builtins.tish",
913
- "uri.tish",
914
- "inc_dec.tish",
915
- "exponentiation.tish",
916
- "void.tish",
917
- "rest_params.tish",
918
- "arrow_functions.tish",
919
- "array_methods.tish",
920
- "types.tish",
921
- ];
1224
+ // cranelift + wasi now use `discover_core_tests()` (full file discovery), like interp/vm/native
1225
+ // the former curated `CRANELIFT_TEST_FILES` allowlist is gone. They embed the bytecode VM, which
1226
+ // has full interp↔vm parity (`VM_PARITY_SKIP` empty), so they inherit it: a disk-safe sweep confirmed
1227
+ // cranelift 66/66 and wasi 66/66. The old blocker was build COST — each backend build used to emit a
1228
+ // per-program `target/` (~2-5 GB) that accumulated to ~130 GB; now `tish_cranelift`/`tish_wasm` build
1229
+ // into a SHARED target dir so the deps compile once (610 MB / 85 MB total for the whole sweep, and a
1230
+ // repeat build is ~1 s). If a construct ever regresses on a backend, add it to a documented skip set
1231
+ // rather than re-introducing an allowlist.
922
1232
 
923
1233
  /// Compile each .tish file with Cranelift backend, run, and compare stdout to static expected (parallelized).
924
1234
  #[test]
@@ -930,7 +1240,7 @@ fn test_mvp_programs_cranelift() {
930
1240
  "tish binary not found at {}. Run `cargo build -p tishlang` first.",
931
1241
  bin.display()
932
1242
  );
933
- let errors: Vec<String> = CRANELIFT_TEST_FILES
1243
+ let errors: Vec<String> = discover_core_tests()
934
1244
  .par_iter()
935
1245
  .filter_map(|name| {
936
1246
  let path = core_dir.join(name);
@@ -994,7 +1304,7 @@ fn test_mvp_programs_wasi() {
994
1304
  "tish binary not found at {}. Run `cargo build -p tishlang` first.",
995
1305
  bin.display()
996
1306
  );
997
- let errors: Vec<String> = CRANELIFT_TEST_FILES
1307
+ let errors: Vec<String> = discover_core_tests()
998
1308
  .par_iter()
999
1309
  .filter_map(|name| {
1000
1310
  let path = core_dir.join(name);
@@ -1057,8 +1367,9 @@ fn test_mvp_programs_js() {
1057
1367
  "tish binary not found at {}. Run `cargo build -p tishlang` first.",
1058
1368
  bin.display()
1059
1369
  );
1060
- for name in MVP_TEST_FILES {
1061
- if JS_SKIP_FILES.contains(name) {
1370
+ for name in &discover_core_tests() {
1371
+ // intentional JS divergences + tests without a `.js` sibling can't use the Node oracle
1372
+ if JS_SKIP_FILES.contains(&name.as_str()) || !has_js_sibling(name) {
1062
1373
  continue;
1063
1374
  }
1064
1375
  let path = core_dir.join(name);
@@ -0,0 +1,45 @@
1
+ //! Issue #60: `try/catch` must catch VM-internal runtime errors (property/method access on
2
+ //! null, calling a non-function) AND `throw`s that propagate across function-call frames,
3
+ //! preserving the thrown value. Verified on the bytecode VM and the tree-walk interpreter
4
+ //! (the two backends #60 targets — the embedded/browser target is the VM, the interpreter is
5
+ //! its oracle). The native backend surfaces these as Rust panics and is tracked separately.
6
+
7
+ use std::path::PathBuf;
8
+ use std::process::Command;
9
+
10
+ const EXPECTED: &str = "\
11
+ A caught
12
+ B caught: boom
13
+ C caught
14
+ D done
15
+ E caught: rethrown
16
+ F ok
17
+ G total: 206
18
+ ";
19
+
20
+ fn run(backend: &str, fixture: &str) -> String {
21
+ let tish = PathBuf::from(env!("CARGO_BIN_EXE_tish"));
22
+ let out = Command::new(&tish)
23
+ .args(["run", "--backend", backend, fixture])
24
+ .output()
25
+ .expect("spawn tish run");
26
+ assert!(
27
+ out.status.success(),
28
+ "tish run --backend {backend} failed: {}",
29
+ String::from_utf8_lossy(&out.stderr)
30
+ );
31
+ String::from_utf8_lossy(&out.stdout).into_owned()
32
+ }
33
+
34
+ #[test]
35
+ fn trycatch_catches_runtime_and_cross_frame_errors() {
36
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
37
+ .join("tests")
38
+ .join("fixtures")
39
+ .join("trycatch_runtime_errors.tish");
40
+ assert!(fixture.is_file(), "missing fixture {}", fixture.display());
41
+ let fixture = fixture.to_str().unwrap();
42
+
43
+ assert_eq!(run("vm", fixture), EXPECTED, "vm output mismatch");
44
+ assert_eq!(run("interp", fixture), EXPECTED, "interp output mismatch");
45
+ }
@@ -0,0 +1,43 @@
1
+ //! Issue #101: the `tish:tty` capability (raw mode, terminal size, key/resize events) is
2
+ //! available on every backend behind the `tty` feature. CI has no real terminal, so this
3
+ //! asserts the module loads and degrades gracefully (size → null, isTTY → a boolean, the
4
+ //! event/raw-mode primitives are functions). Interactive behavior is covered manually.
5
+
6
+ use std::path::PathBuf;
7
+ use std::process::Command;
8
+
9
+ const EXPECTED: &str = "\
10
+ isTTY: boolean
11
+ size: null
12
+ setRawMode: function
13
+ read: function
14
+ readLine: function
15
+ alt: function function
16
+ ";
17
+
18
+ fn run(backend: &str, fixture: &str) -> String {
19
+ let tish = PathBuf::from(env!("CARGO_BIN_EXE_tish"));
20
+ let out = Command::new(&tish)
21
+ .args(["run", "--backend", backend, "--feature", "tty", fixture])
22
+ .output()
23
+ .expect("spawn tish run");
24
+ assert!(
25
+ out.status.success(),
26
+ "tish run --backend {backend} failed: {}",
27
+ String::from_utf8_lossy(&out.stderr)
28
+ );
29
+ String::from_utf8_lossy(&out.stdout).into_owned()
30
+ }
31
+
32
+ #[test]
33
+ fn tty_module_loads_and_degrades_without_a_terminal() {
34
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35
+ .join("tests")
36
+ .join("fixtures")
37
+ .join("tty_capability.tish");
38
+ assert!(fixture.is_file(), "missing fixture {}", fixture.display());
39
+ let fixture = fixture.to_str().unwrap();
40
+
41
+ assert_eq!(run("vm", fixture), EXPECTED, "vm output mismatch");
42
+ assert_eq!(run("interp", fixture), EXPECTED, "interp output mismatch");
43
+ }