@tishlang/tish-lint 3.6.0 → 3.7.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 (36) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-lint +0 -0
  3. package/crates/tish/Cargo.toml +4 -1
  4. package/crates/tish/src/cli_help.rs +84 -0
  5. package/crates/tish/src/main.rs +93 -1
  6. package/crates/tish/tests/test_runner.rs +673 -0
  7. package/crates/tish_bytecode/src/compiler.rs +25 -4
  8. package/crates/tish_compile/src/resolve.rs +45 -12
  9. package/crates/tish_core/src/lib.rs +103 -11
  10. package/crates/tish_eval/src/eval.rs +16 -0
  11. package/crates/tish_eval/src/value_convert.rs +10 -0
  12. package/crates/tish_lint/Cargo.toml +1 -1
  13. package/crates/tish_test/Cargo.toml +47 -0
  14. package/crates/tish_test/src/assert.rs +435 -0
  15. package/crates/tish_test/src/config.rs +85 -0
  16. package/crates/tish_test/src/coverage.rs +237 -0
  17. package/crates/tish_test/src/deep_equal.rs +261 -0
  18. package/crates/tish_test/src/discovery.rs +70 -0
  19. package/crates/tish_test/src/expect.rs +977 -0
  20. package/crates/tish_test/src/instrument.rs +457 -0
  21. package/crates/tish_test/src/isolation.rs +14 -0
  22. package/crates/tish_test/src/lib.rs +22 -0
  23. package/crates/tish_test/src/load.rs +152 -0
  24. package/crates/tish_test/src/mocks.rs +270 -0
  25. package/crates/tish_test/src/module.rs +318 -0
  26. package/crates/tish_test/src/registry.rs +235 -0
  27. package/crates/tish_test/src/report.rs +312 -0
  28. package/crates/tish_test/src/runner.rs +797 -0
  29. package/crates/tish_test/src/snapshots.rs +197 -0
  30. package/crates/tish_vm/src/vm.rs +17 -14
  31. package/package.json +1 -1
  32. package/platform/darwin-arm64/tish-lint +0 -0
  33. package/platform/darwin-x64/tish-lint +0 -0
  34. package/platform/linux-arm64/tish-lint +0 -0
  35. package/platform/linux-x64/tish-lint +0 -0
  36. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -1521,6 +1521,27 @@ impl<'a> Compiler<'a> {
1521
1521
  Ok(())
1522
1522
  }
1523
1523
 
1524
+ /// Compile a function/arrow body **without** the outermost block's `EnterBlock`/`ExitBlock`.
1525
+ ///
1526
+ /// A function body's top-level block is the frame's own scope: its bindings live in the
1527
+ /// frame-local map that is created fresh per call and dropped when the call returns, so there
1528
+ /// is nothing for `ExitBlock` to restore or hide. Emitting it is not merely wasted work — it is
1529
+ /// actively wrong. `ExitBlock` REMOVES the block's names from that map, and closures capture
1530
+ /// the map **by reference**, so any closure that outlived the call lost its captures
1531
+ /// ("Undefined variable"). An explicit `return` masked the bug by jumping over the teardown.
1532
+ /// See tests/core/parity_block_scope_capture.tish (#247).
1533
+ fn compile_fn_body(&mut self, body: &Statement) -> Result<(), CompileError> {
1534
+ if let Statement::Block { statements, .. } = body {
1535
+ self.enter_block_scope();
1536
+ for stmt in statements {
1537
+ self.compile_statement(stmt)?;
1538
+ }
1539
+ self.exit_block_scope();
1540
+ return Ok(());
1541
+ }
1542
+ self.compile_statement(body)
1543
+ }
1544
+
1524
1545
  fn compile_statement(&mut self, stmt: &Statement) -> Result<(), CompileError> {
1525
1546
  self.mark_line(stmt.span());
1526
1547
  match stmt {
@@ -1999,7 +2020,7 @@ impl<'a> Compiler<'a> {
1999
2020
  if let Some(map) = simple_slots {
2000
2021
  inner_comp.slot_ctx = Some(map);
2001
2022
  inner_comp.emit_param_defaults_prologue(params)?;
2002
- inner_comp.compile_statement(body)?;
2023
+ inner_comp.compile_fn_body(body)?;
2003
2024
  } else if let Some(cap) = captured {
2004
2025
  // Params (all uncaptured — gated) → slots 0..n (matching the VM's param binding);
2005
2026
  // uncaptured body `let`s get fresh slots via the scope-aware allocator; captured
@@ -2011,7 +2032,7 @@ impl<'a> Compiler<'a> {
2011
2032
  inner_comp.declare_slot(p);
2012
2033
  }
2013
2034
  inner_comp.emit_param_defaults_prologue(params)?;
2014
- inner_comp.compile_statement(body)?;
2035
+ inner_comp.compile_fn_body(body)?;
2015
2036
  general_frame_slots = Some(inner_comp.next_slot);
2016
2037
  } else {
2017
2038
  inner_comp.scope = vec![param_names
@@ -2021,7 +2042,7 @@ impl<'a> Compiler<'a> {
2021
2042
  inner_comp
2022
2043
  .emit_param_destructure_prologue(&param_names[..formal_len], &slots)?;
2023
2044
  inner_comp.emit_param_defaults_prologue(params)?;
2024
- inner_comp.compile_statement(body)?;
2045
+ inner_comp.compile_fn_body(body)?;
2025
2046
  }
2026
2047
  inner_comp.emit(Opcode::LoadConst);
2027
2048
  let idx = inner_comp.constant_idx(Constant::Null);
@@ -2769,7 +2790,7 @@ impl<'a> Compiler<'a> {
2769
2790
  inner_comp.emit(Opcode::Return);
2770
2791
  }
2771
2792
  ArrowBody::Block(s) => {
2772
- inner_comp.compile_statement(s)?;
2793
+ inner_comp.compile_fn_body(s)?;
2773
2794
  let idx = inner_comp.constant_idx(Constant::Null);
2774
2795
  inner_comp.emit(Opcode::LoadConst);
2775
2796
  inner_comp.chunk.write_u16(idx);
@@ -81,13 +81,22 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
81
81
  ("encoding", "tish:encoding"),
82
82
  ("crypto", "tish:crypto"),
83
83
  ("zip", "tish:zip"),
84
+ ("test", "tish:test"),
85
+ ("assert", "tish:assert"),
86
+ ("assert/strict", "tish:assert"),
84
87
  ];
85
88
 
86
89
  /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
87
- /// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`) and bare aliases.
90
+ /// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`,
91
+ /// `node:test` -> `tish:test`, `node:assert` / `node:assert/strict` -> `tish:assert`)
92
+ /// and bare aliases.
88
93
  pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
89
94
  // Strip a leading `node:` so `node:fs` resolves the same as `fs`.
90
95
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
96
+ // `node:assert/strict` (and bare `assert/strict`) → `tish:assert`.
97
+ if spec == "assert/strict" {
98
+ return Some("tish:assert".to_string());
99
+ }
91
100
  if spec.starts_with("tish:") {
92
101
  return Some(spec.to_string());
93
102
  }
@@ -97,7 +106,7 @@ pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
97
106
  .map(|(_, canonical)| (*canonical).to_string())
98
107
  }
99
108
 
100
- /// Built-in modules that come from tishlang_runtime, not from package.json.
109
+ /// Built-in modules that come from tishlang_runtime / tishlang_test, not from package.json.
101
110
  pub fn is_builtin_native_spec(spec: &str) -> bool {
102
111
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
103
112
  matches!(
@@ -114,10 +123,12 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
114
123
  | "tish:encoding"
115
124
  | "tish:crypto"
116
125
  | "tish:zip"
126
+ | "tish:test"
127
+ | "tish:assert"
117
128
  ) || matches!(
118
129
  spec,
119
130
  "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty" | "net"
120
- | "encoding" | "crypto" | "zip"
131
+ | "encoding" | "crypto" | "zip" | "test" | "assert" | "assert/strict"
121
132
  )
122
133
  }
123
134
 
@@ -1031,7 +1042,7 @@ fn load_module_recursive(
1031
1042
  /// resolution for `import … from "@scope/pkg"` is interop, and the native-vs-merged axis is the one
1032
1043
  /// piece tish adds on top, keyed off the native markers rather than mere presence of `tish.module`.
1033
1044
  pub fn is_native_import(spec: &str) -> bool {
1034
- // A leading `node:` (node:fs, node:fs/promises) resolves the same as the bare form.
1045
+ // A leading `node:` (node:fs, node:fs/promises, node:assert/strict) resolves like the bare form.
1035
1046
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
1036
1047
  spec.starts_with("tish:")
1037
1048
  || spec.starts_with("cargo:")
@@ -1039,7 +1050,7 @@ pub fn is_native_import(spec: &str) -> bool {
1039
1050
  || matches!(
1040
1051
  spec,
1041
1052
  "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "net" | "encoding"
1042
- | "crypto" | "zip"
1053
+ | "crypto" | "zip" | "test" | "assert" | "assert/strict"
1043
1054
  )
1044
1055
  }
1045
1056
 
@@ -2110,13 +2121,35 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
2110
2121
  from.as_ref()
2111
2122
  ));
2112
2123
  }
2113
- ImportSpecifier::Default { name, .. } => {
2114
- return Err(format!(
2115
- "Default import '{}' not supported for native module '{}'. Use named import, e.g. import {{ egui }} from '{}'",
2116
- name.as_ref(),
2117
- from.as_ref(),
2118
- from.as_ref()
2119
- ));
2124
+ ImportSpecifier::Default { name, name_span } => {
2125
+ // Only `tish:assert` (and aliases) expose a callable default.
2126
+ // Other natives still require named imports.
2127
+ if canonical_spec != "tish:assert" {
2128
+ return Err(format!(
2129
+ "Default import '{}' not supported for native module '{}'. Use named import, e.g. import {{ egui }} from '{}'",
2130
+ name.as_ref(),
2131
+ from.as_ref(),
2132
+ from.as_ref()
2133
+ ));
2134
+ }
2135
+ let init = Expr::NativeModuleLoad {
2136
+ spec: Arc::from(canonical_spec.clone()),
2137
+ export_name: Arc::from("default"),
2138
+ span: *span,
2139
+ };
2140
+ merge_push(
2141
+ &mut statements,
2142
+ &mut statement_sources,
2143
+ Statement::VarDecl {
2144
+ name: name.clone(),
2145
+ name_span: *name_span,
2146
+ mutable: false,
2147
+ type_ann: None,
2148
+ init: Some(init),
2149
+ span: *span,
2150
+ },
2151
+ src_path.clone(),
2152
+ );
2120
2153
  }
2121
2154
  }
2122
2155
  }
@@ -109,17 +109,17 @@ pub fn process_argv() -> Vec<String> {
109
109
  }
110
110
  }
111
111
 
112
- /// #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
113
- ///
114
- /// The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
115
- /// `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
116
- /// up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
117
- /// `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
118
- /// elements so they stop iterating promptly instead of running the callback for the rest of the
119
- /// array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
120
- /// can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
121
- /// share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
122
- /// that converts it back into a `Result`.
112
+ // #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
113
+ //
114
+ // The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
115
+ // `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
116
+ // up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
117
+ // `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
118
+ // elements so they stop iterating promptly instead of running the callback for the rest of the
119
+ // array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
120
+ // can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
121
+ // share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
122
+ // that converts it back into a `Result`.
123
123
  #[cfg(not(feature = "portable"))]
124
124
  thread_local! {
125
125
  static PENDING_THROW: core::cell::RefCell<Option<Value>> = const { core::cell::RefCell::new(None) };
@@ -151,6 +151,98 @@ pub fn take_pending_throw() -> Option<Value> {
151
151
  PENDING_THROW.with(|c| c.borrow_mut().take())
152
152
  }
153
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Cooperative execution deadline (`tish test --timeout`).
156
+ //
157
+ // Without this a runaway loop is uninterruptible: the runner can only notice a blown
158
+ // deadline *after* the body returns, which never happens. Backends poll
159
+ // [`execution_deadline_exceeded`] on loop back-edges and abort with a catchable error.
160
+ //
161
+ // Disarmed is the default and costs one relaxed load — `tish run` never arms it. Armed,
162
+ // the clock is read once every `DEADLINE_POLL_MASK + 1` back-edges so a hot loop is not
163
+ // paying a `now()` per iteration.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ #[cfg(not(feature = "portable"))]
167
+ mod deadline {
168
+ use core::sync::atomic::{AtomicU64, Ordering};
169
+ use std::sync::LazyLock;
170
+ use std::time::Instant;
171
+
172
+ /// Nanoseconds since [`START`] at which execution must stop. 0 = disarmed.
173
+ static DEADLINE_NS: AtomicU64 = AtomicU64::new(0);
174
+ static START: LazyLock<Instant> = LazyLock::new(Instant::now);
175
+
176
+ const DEADLINE_POLL_MASK: u32 = 0x3FF;
177
+
178
+ thread_local! {
179
+ static POLL_TICK: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
180
+ static TRIPPED: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
181
+ }
182
+
183
+ /// Arm the deadline `millis` from now, or disarm with `None`. Clears any previous trip.
184
+ pub fn set_execution_deadline(millis: Option<u64>) {
185
+ TRIPPED.with(|t| t.set(false));
186
+ POLL_TICK.with(|t| t.set(0));
187
+ match millis {
188
+ Some(ms) => {
189
+ let at = START.elapsed().as_nanos() as u64 + ms.saturating_mul(1_000_000);
190
+ // 0 means "disarmed"; nudge an exact-zero deadline to 1ns.
191
+ DEADLINE_NS.store(at.max(1), Ordering::Relaxed);
192
+ }
193
+ None => DEADLINE_NS.store(0, Ordering::Relaxed),
194
+ }
195
+ }
196
+
197
+ /// Has the armed deadline passed? Cheap and false when disarmed.
198
+ #[inline]
199
+ pub fn execution_deadline_exceeded() -> bool {
200
+ let at = DEADLINE_NS.load(Ordering::Relaxed);
201
+ if at == 0 {
202
+ return false;
203
+ }
204
+ if TRIPPED.with(|t| t.get()) {
205
+ return true;
206
+ }
207
+ let tick = POLL_TICK.with(|t| {
208
+ let n = t.get().wrapping_add(1);
209
+ t.set(n);
210
+ n
211
+ });
212
+ if tick & DEADLINE_POLL_MASK != 0 {
213
+ return false;
214
+ }
215
+ if START.elapsed().as_nanos() as u64 >= at {
216
+ TRIPPED.with(|t| t.set(true));
217
+ return true;
218
+ }
219
+ false
220
+ }
221
+
222
+ /// Did this thread abort because the deadline tripped?
223
+ pub fn execution_deadline_tripped() -> bool {
224
+ TRIPPED.with(|t| t.get())
225
+ }
226
+ }
227
+
228
+ // Portable / single-core builds (GBA and friends) have no wall clock to poll.
229
+ #[cfg(feature = "portable")]
230
+ mod deadline {
231
+ pub fn set_execution_deadline(_millis: Option<u64>) {}
232
+ #[inline]
233
+ pub fn execution_deadline_exceeded() -> bool {
234
+ false
235
+ }
236
+ pub fn execution_deadline_tripped() -> bool {
237
+ false
238
+ }
239
+ }
240
+
241
+ pub use deadline::{execution_deadline_exceeded, execution_deadline_tripped, set_execution_deadline};
242
+
243
+ /// Message backends raise when [`execution_deadline_exceeded`] trips.
244
+ pub const DEADLINE_ERROR: &str = "Test exceeded its timeout and was aborted";
245
+
154
246
  // Current user-function call depth for the bytecode VM. The VM's recursive path builds a fresh
155
247
  // `Vm` per call (so no shared struct field can accumulate) and its `Callable::call` signature is
156
248
  // fixed — a thread-local is the VM's equivalent of the interpreter's shared `Rc<Cell>` counter.
@@ -639,6 +639,7 @@ impl Evaluator {
639
639
  }
640
640
  Statement::While { cond, body, .. } => {
641
641
  loop {
642
+ deadline_check()?;
642
643
  if !self.eval_expr(cond)?.is_truthy() {
643
644
  break;
644
645
  }
@@ -778,6 +779,10 @@ impl Evaluator {
778
779
  };
779
780
  let mut ret = Ok(Value::Null);
780
781
  loop {
782
+ if let Err(e) = deadline_check() {
783
+ ret = Err(e);
784
+ break;
785
+ }
781
786
  self.scope = Rc::clone(&loop_env);
782
787
  let cond_ok = match cond.as_ref() {
783
788
  Some(c) => match self.eval_expr(c) {
@@ -909,6 +914,7 @@ impl Evaluator {
909
914
  }
910
915
  Statement::DoWhile { body, cond, .. } => {
911
916
  loop {
917
+ deadline_check()?;
912
918
  match self.eval_statement(body) {
913
919
  Ok(_) => {}
914
920
  Err(EvalError::Break) => break,
@@ -5389,6 +5395,16 @@ impl Evaluator {
5389
5395
  }
5390
5396
  }
5391
5397
 
5398
+ /// Cooperative timeout poll for loop back-edges (`tish test --timeout`). Disarmed on every
5399
+ /// other path, where it costs one relaxed atomic load.
5400
+ #[inline]
5401
+ fn deadline_check() -> Result<(), EvalError> {
5402
+ if tishlang_core::execution_deadline_exceeded() {
5403
+ return Err(EvalError::Error(tishlang_core::DEADLINE_ERROR.to_string()));
5404
+ }
5405
+ Ok(())
5406
+ }
5407
+
5392
5408
  #[derive(Debug)]
5393
5409
  enum EvalError {
5394
5410
  Return(Value),
@@ -187,6 +187,16 @@ pub fn eval_to_core(v: &Value) -> Result<CoreValue, String> {
187
187
  }
188
188
  Value::Symbol(s) => Ok(CoreValue::Symbol(Arc::clone(s))),
189
189
  Value::Opaque(o) => Ok(CoreValue::Opaque(Arc::clone(o))),
190
+ // A core function that came IN through `core_to_eval` round-trips back out unchanged.
191
+ Value::CoreFn(f) => Ok(CoreValue::Function(f.clone())),
192
+ // Interpreter closures carry an AST body plus a scope that only the `Evaluator` can
193
+ // resolve, and they do not outlive it. Say so instead of printing a discriminant.
194
+ Value::Function { .. } | Value::Native(_) => Err(
195
+ "Cannot pass an interpreter function to a native module: interpreter closures are \
196
+ bound to the evaluator and cannot be invoked from native code. Use the default \
197
+ (VM) backend."
198
+ .to_string(),
199
+ ),
190
200
  _ => Err(format!(
191
201
  "Cannot pass {:?} to native function (unsupported type)",
192
202
  std::mem::discriminant(v)
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lint"
3
- version = "3.6.0"
3
+ version = "3.7.0"
4
4
  edition = "2021"
5
5
  description = "AST-based linter for Tish"
6
6
  license-file = { workspace = true }
@@ -0,0 +1,47 @@
1
+ [package]
2
+ name = "tishlang_test"
3
+ version = "0.0.0-dev"
4
+ edition = "2021"
5
+ description = "Native tish test runner, expect, and Node-compatible assert"
6
+ license-file = { workspace = true }
7
+ repository = { workspace = true }
8
+
9
+ [features]
10
+ default = ["fs", "regex", "promise"]
11
+ # Fan features into runtime + optional eval/vm so send-values / regex stay consistent.
12
+ fs = ["tishlang_runtime/fs", "tishlang_eval/fs", "tishlang_vm/fs"]
13
+ regex = [
14
+ "tishlang_core/regex",
15
+ "tishlang_runtime/regex",
16
+ "tishlang_eval/regex",
17
+ "tishlang_vm/regex",
18
+ ]
19
+ promise = ["tishlang_runtime/promise", "tishlang_vm/promise"]
20
+ # Full runner needs compile + VM to load files
21
+ runner = [
22
+ "dep:tishlang_compile",
23
+ "dep:tishlang_opt",
24
+ "dep:tishlang_bytecode",
25
+ "dep:tishlang_vm",
26
+ "dep:tishlang_ast",
27
+ "dep:tishlang_eval",
28
+ ]
29
+ watch = ["dep:notify"]
30
+
31
+ [dependencies]
32
+ tishlang_core = { path = "../tish_core", version = ">=0.1" }
33
+ tishlang_builtins = { path = "../tish_builtins", version = ">=0.1" }
34
+ tishlang_runtime = { path = "../tish_runtime", version = ">=0.1" }
35
+ tishlang_ast = { path = "../tish_ast", version = ">=0.1", optional = true }
36
+ tishlang_compile = { path = "../tish_compile", version = ">=0.1", optional = true }
37
+ tishlang_opt = { path = "../tish_opt", version = ">=0.1", optional = true }
38
+ tishlang_bytecode = { path = "../tish_bytecode", version = ">=0.1", optional = true }
39
+ tishlang_vm = { path = "../tish_vm", version = ">=0.1", optional = true }
40
+ tishlang_eval = { path = "../tish_eval", version = ">=0.1", optional = true }
41
+ serde_json = "1"
42
+ walkdir = "2"
43
+ regex = "1"
44
+ notify = { version = "7", optional = true }
45
+
46
+ [dev-dependencies]
47
+ tempfile = "3"