@tishlang/tish-lint 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-lint +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/src/lib.rs +94 -1
  60. package/crates/tish_lexer/src/lib.rs +76 -0
  61. package/crates/tish_lexer/src/token.rs +4 -0
  62. package/crates/tish_lint/Cargo.toml +1 -1
  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-lint +0 -0
  85. package/platform/darwin-x64/tish-lint +0 -0
  86. package/platform/linux-arm64/tish-lint +0 -0
  87. package/platform/linux-x64/tish-lint +0 -0
  88. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -29,6 +29,10 @@ pub enum RustType {
29
29
  Vec(Box<RustType>),
30
30
  /// Option<T> for nullable types (T | null)
31
31
  Option(Box<RustType>),
32
+ /// Box<T> — heap indirection required to make recursive structs finite-sized.
33
+ /// Only the recursive-struct native pass (#178) produces this, for child fields
34
+ /// like `Option<Box<TishRec_Node>>`; never from `from_annotation`.
35
+ Boxed(Box<RustType>),
32
36
  /// Inline object shape — used during inference / annotation lowering
33
37
  /// before a `Named` alias has been registered. Once a corresponding
34
38
  /// `type Foo = { ... }` declaration is found in the program, occurrences
@@ -49,6 +53,15 @@ pub enum RustType {
49
53
  },
50
54
  /// Tuple `(T0, T1, …)` for `[T0, T1]` tuple types — a native Rust tuple.
51
55
  Tuple(Vec<RustType>),
56
+ /// #179 Stage B: a closed set of 2..=8 distinct primitive-record shapes read at one site — the
57
+ /// "megamorphic" array-of-heterogeneous-objects pattern. Emitted as a generated Rust enum
58
+ /// `TishUnion_<name> { V0(TishStruct_<v0>), … }` (one variant per shape); a `.field` read present
59
+ /// in EVERY variant lowers to a `match` with a direct field load per arm (no hash, no IC). `name`
60
+ /// is the union alias; each `variants` entry is `(per-variant struct alias, its fields)`.
61
+ ShapeUnion {
62
+ name: Arc<str>,
63
+ variants: Vec<(Arc<str>, Vec<(Arc<str>, RustType)>)>,
64
+ },
52
65
  }
53
66
 
54
67
  impl RustType {
@@ -95,6 +108,12 @@ impl RustType {
95
108
  RustType::Vec(Box::new(Self::from_annotation_with_aliases(elem, aliases)))
96
109
  }
97
110
  TypeAnnotation::Object(fields) => {
111
+ // Security #379: a field key that is not a valid Rust identifier must not drive a
112
+ // native struct (it would be interpolated into generated Rust). Keep the whole object
113
+ // on the boxed `Value` path — always correct, just unspecialized.
114
+ if fields.iter().any(|(k, _)| !is_struct_field_safe(k)) {
115
+ return RustType::Value;
116
+ }
98
117
  let typed_fields: Vec<_> = fields
99
118
  .iter()
100
119
  .map(|(k, v)| (k.clone(), Self::from_annotation_with_aliases(v, aliases)))
@@ -234,6 +253,7 @@ impl RustType {
234
253
  RustType::Unit => "()".to_string(),
235
254
  RustType::Vec(inner) => format!("Vec<{}>", inner.to_rust_type_str()),
236
255
  RustType::Option(inner) => format!("Option<{}>", inner.to_rust_type_str()),
256
+ RustType::Boxed(inner) => format!("Box<{}>", inner.to_rust_type_str()),
237
257
  RustType::Object(_) => {
238
258
  // Anonymous inline shapes don't have a Rust struct; fall
239
259
  // back to the dynamic Value path.
@@ -249,6 +269,7 @@ impl RustType {
249
269
  )
250
270
  }
251
271
  RustType::Tuple(elems) => tuple_text(&elems.iter().map(|e| e.to_rust_type_str()).collect::<Vec<_>>()),
272
+ RustType::ShapeUnion { name, .. } => shape_union_enum_ident(name),
252
273
  }
253
274
  }
254
275
 
@@ -263,6 +284,7 @@ impl RustType {
263
284
  RustType::Unit => "()".to_string(),
264
285
  RustType::Vec(_) => "Vec::new()".to_string(),
265
286
  RustType::Option(_) => "None".to_string(),
287
+ RustType::Boxed(inner) => format!("Box::new({})", inner.default_value()),
266
288
  RustType::Object(_) => "Value::Null".to_string(),
267
289
  RustType::Named { fields, .. } => {
268
290
  // Build a literal struct with each field at its own default,
@@ -285,6 +307,22 @@ impl RustType {
285
307
  RustType::Tuple(elems) => {
286
308
  tuple_text(&elems.iter().map(|e| e.default_value()).collect::<Vec<_>>())
287
309
  }
310
+ RustType::ShapeUnion { name, variants } => {
311
+ // Default = the first variant with each of its fields defaulted.
312
+ let (v0_alias, v0_fields) = &variants[0];
313
+ let init = v0_fields
314
+ .iter()
315
+ .map(|(k, t)| format!("{}: {}", field_ident(k), t.default_value()))
316
+ .collect::<Vec<_>>()
317
+ .join(", ");
318
+ format!(
319
+ "{}::{}({} {{ {} }})",
320
+ shape_union_enum_ident(name),
321
+ shape_union_variant_ident(0),
322
+ named_struct_ident(v0_alias),
323
+ init
324
+ )
325
+ }
288
326
  }
289
327
  }
290
328
 
@@ -344,20 +382,34 @@ impl RustType {
344
382
  )
345
383
  }
346
384
  RustType::Named { name, fields } => {
347
- // Each field is fetched out of the Value::Object via
348
- // `get_prop` and converted to its native type. Falls back
349
- // to the field's `default_value()` if the field is absent
350
- // (rare — usually these come from JSON or PG).
385
+ // Each field is fetched out of the Value::Object via `get_prop` and converted to its
386
+ // native type. The source Value is bound ONCE to `_src` so a non-trivial `value_expr`
387
+ // (an object literal, a call, …) is evaluated a single time instead of being textually
388
+ // re-inlined per field (which would re-allocate the whole object N times). Missing
389
+ // fields fall back to `default_value()` (rare — usually these come from JSON or PG).
351
390
  let field_assigns = fields
352
391
  .iter()
353
392
  .map(|(k, ty)| {
354
- let fetch =
355
- format!("tishlang_runtime::get_prop(&{}, {:?})", value_expr, k.as_ref());
393
+ let fetch = format!("tishlang_runtime::get_prop(&_src, {:?})", k.as_ref());
356
394
  format!("{}: {}", field_ident(k), ty.from_value_expr(&fetch))
357
395
  })
358
396
  .collect::<Vec<_>>()
359
397
  .join(", ");
360
- format!("{} {{ {} }}", named_struct_ident(name), field_assigns)
398
+ format!(
399
+ "{{ let _src = {}; {} {{ {} }} }}",
400
+ value_expr,
401
+ named_struct_ident(name),
402
+ field_assigns
403
+ )
404
+ }
405
+ RustType::ShapeUnion { .. } => {
406
+ // #179 Stage B: converting a boxed Value INTO a ShapeUnion is a boundary the safety
407
+ // walk forbids (a typed union element never crosses to/from boxed), so codegen must
408
+ // never emit this. Present for match-completeness; fails loud if gating is violated.
409
+ format!(
410
+ "{{ let _ = {}; unreachable!(\"ShapeUnion from Value is a forbidden boundary\") }}",
411
+ value_expr
412
+ )
361
413
  }
362
414
  _ => value_expr.to_string(), // Fallback
363
415
  }
@@ -403,12 +455,15 @@ impl RustType {
403
455
  )
404
456
  }
405
457
  RustType::Named { fields, .. } => {
406
- // Walk fields, build an ObjectMap, wrap in Value::Object.
407
- // The boundary is paid only when crossing into untyped
408
- // Tish (JSON.stringify, calling a Value::Function, etc.);
409
- // direct Rust-to-Rust paths between two Named values stay
410
- // as plain struct moves.
411
- let inserts = fields
458
+ // Build the boxed Value with an ORDERED PropMap via `object_from_pairs` (no
459
+ // intermediate `AHashMap`), so key order == field-declaration order == JS
460
+ // insertion order. The old `ObjectMap::default()` (`AHashMap`) + insert path
461
+ // scrambled key order NON-DETERMINISTICALLY per run (ahash seed) — a shipped
462
+ // `JSON.stringify` / `Object.keys` / `for..in` divergence from node whenever a
463
+ // native struct crosses back into untyped Tish. This boundary is paid only on
464
+ // that crossing (JSON.stringify, calling a Value::Function, etc.); direct
465
+ // Rust-to-Rust paths between two Named values stay as plain struct moves.
466
+ let pairs = fields
412
467
  .iter()
413
468
  .map(|(k, ty)| {
414
469
  let access = format!("{}.{}", native_expr, field_ident(k));
@@ -420,22 +475,35 @@ impl RustType {
420
475
  } else {
421
476
  ty.to_value_expr(&access)
422
477
  };
478
+ format!("(::std::sync::Arc::from({:?}), {})", k.as_ref(), v_expr)
479
+ })
480
+ .collect::<Vec<_>>()
481
+ .join(", ");
482
+ // `object_from_pairs::<N>` builds the `PropMap` in one ordered pass (N = field
483
+ // count, a codegen-time constant) — no AHashMap, so key order is preserved.
484
+ format!("Value::object_from_pairs([{}])", pairs)
485
+ }
486
+ RustType::ShapeUnion { name, variants } => {
487
+ // union → boxed Value::Object: one match arm per variant, delegating to that
488
+ // variant's Named glue (ordered `object_from_pairs`, preserving JS key order).
489
+ let arms = variants
490
+ .iter()
491
+ .enumerate()
492
+ .map(|(i, (alias, fields))| {
493
+ let named = RustType::Named {
494
+ name: alias.clone(),
495
+ fields: fields.clone(),
496
+ };
423
497
  format!(
424
- "_om.insert(::std::sync::Arc::from({:?}), {});",
425
- k.as_ref(),
426
- v_expr
498
+ "{}::{}(__u) => {}",
499
+ shape_union_enum_ident(name),
500
+ shape_union_variant_ident(i),
501
+ named.to_value_expr("__u")
427
502
  )
428
503
  })
429
504
  .collect::<Vec<_>>()
430
- .join(" ");
431
- // `Value::object` wraps the `ObjectMap` in an `ObjectData` (what `Value::Object`
432
- // actually holds) — emitting `Value::Object(VmRef::new(_om))` directly is a type
433
- // error (`ObjectMap` != `ObjectData`); only surfaced once a whole struct is boxed
434
- // into a `Value`, e.g. passing it to a function.
435
- format!(
436
- "{{ let mut _om = ObjectMap::default(); {} Value::object(_om) }}",
437
- inserts
438
- )
505
+ .join(", ");
506
+ format!("(match &{} {{ {} }})", native_expr, arms)
439
507
  }
440
508
  _ => native_expr.to_string(), // Fallback
441
509
  }
@@ -457,16 +525,75 @@ pub fn named_struct_ident(tish_name: &str) -> String {
457
525
  format!("TishStruct_{}", tish_name)
458
526
  }
459
527
 
528
+ /// #179 Stage B: map a shape-union alias to the generated Rust enum identifier.
529
+ pub fn shape_union_enum_ident(tish_name: &str) -> String {
530
+ format!("TishUnion_{}", tish_name)
531
+ }
532
+
533
+ /// #179 Stage B: the Rust variant identifier for the `idx`-th shape of a union (`V0`, `V1`, …).
534
+ pub fn shape_union_variant_ident(idx: usize) -> String {
535
+ format!("V{}", idx)
536
+ }
537
+
538
+ /// Keywords that cannot be written as a raw identifier (`r#self` etc. are a hard Rust error), so an
539
+ /// object key equal to one of these must NOT drive struct/shape inference — it stays boxed.
540
+ const NON_RAWABLE_KEYWORDS: &[&str] = &["self", "Self", "super", "crate"];
541
+
542
+ /// True iff `key` can be safely emitted as a generated Rust struct field name via [`field_ident`]:
543
+ /// a plain ASCII identifier (`^[A-Za-z_][A-Za-z0-9_]*$`), excluding the bare wildcard `_` and the
544
+ /// four non-rawable keywords. Any other key — one containing spaces/punctuation, empty, non-ASCII,
545
+ /// etc. — is REJECTED so the object it belongs to falls back to the boxed `Value` path instead of
546
+ /// being lowered to a native struct.
547
+ ///
548
+ /// This is the front-line guard for the native-codegen key-injection class (security #379): an
549
+ /// object literal key is arbitrary tish/JS text, and interpolating it verbatim into generated Rust
550
+ /// (`pub {key}: {ty},`) is arbitrary-code injection. Inference/annotation lowering call this to keep
551
+ /// unsafe-keyed objects on the always-correct boxed path; [`field_ident`] additionally sanitizes as a
552
+ /// last-resort choke point so no path can ever emit a non-identifier verbatim.
553
+ pub fn is_struct_field_safe(key: &str) -> bool {
554
+ if key == "_" || NON_RAWABLE_KEYWORDS.contains(&key) {
555
+ return false;
556
+ }
557
+ let mut chars = key.chars();
558
+ match chars.next() {
559
+ Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
560
+ _ => return false,
561
+ }
562
+ chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
563
+ }
564
+
565
+ /// FNV-1a 64-bit — a tiny, dependency-free hash used only to synthesize a deterministic safe field
566
+ /// identifier for an (unexpected) non-identifier key. Deterministic so a struct's field definition,
567
+ /// its constructor, its accessors, and its serializer all agree on the same name.
568
+ fn fnv1a_64(s: &str) -> u64 {
569
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
570
+ for b in s.as_bytes() {
571
+ h ^= *b as u64;
572
+ h = h.wrapping_mul(0x0000_0100_0000_01b3);
573
+ }
574
+ h
575
+ }
576
+
460
577
  /// Map a Tish field name (`randomNumber`) to a valid Rust identifier
461
578
  /// (kept identical here — non-snake-case is allowed via
462
579
  /// `#[allow(non_snake_case)]` on the struct, so JS-style camelCase keys
463
580
  /// stay readable in the generated source).
581
+ ///
582
+ /// A key that is not a valid Rust identifier is NEVER emitted verbatim (that would be code injection,
583
+ /// security #379): inference/annotation lowering should already have kept such an object boxed via
584
+ /// [`is_struct_field_safe`], so this is a defense-in-depth choke point — an unsafe key is replaced by
585
+ /// a deterministic `__tish_field_<hash>` name. The worst case if a producer is missed is thus a wrong
586
+ /// field name (a compile error / correctness bug), never injected Rust.
464
587
  pub fn field_ident(tish_name: &str) -> String {
465
- // Reserve Rust keywords that would otherwise conflict.
588
+ if !is_struct_field_safe(tish_name) {
589
+ return format!("__tish_field_{:016x}", fnv1a_64(tish_name));
590
+ }
591
+ // Reserve Rust keywords that would otherwise conflict. (The non-rawable keywords are already
592
+ // excluded by `is_struct_field_safe` above, so every keyword reaching here has a valid `r#` form.)
466
593
  match tish_name {
467
- "type" | "ref" | "fn" | "match" | "move" | "mod" | "self" | "Self" | "super" | "use"
594
+ "type" | "ref" | "fn" | "match" | "move" | "mod" | "use"
468
595
  | "where" | "loop" | "yield" | "async" | "await" | "dyn" | "impl" | "trait" | "in"
469
- | "as" | "box" | "crate" | "const" | "extern" | "let" | "mut" | "pub" | "static"
596
+ | "as" | "box" | "const" | "extern" | "let" | "mut" | "pub" | "static"
470
597
  | "unsafe" | "abstract" | "become" | "do" | "final" | "macro" | "override" | "priv"
471
598
  | "typeof" | "unsized" | "virtual" => format!("r#{}", tish_name),
472
599
  _ => tish_name.to_string(),
@@ -571,6 +698,75 @@ mod tests {
571
698
  );
572
699
  }
573
700
 
701
+ // ---- security #379: object-literal key injection into generated Rust ----
702
+
703
+ #[test]
704
+ fn is_struct_field_safe_accepts_plain_and_camel_and_keywords() {
705
+ for k in ["x", "y", "randomNumber", "_priv", "a1", "TishAnon_0", "type", "fn", "match"] {
706
+ assert!(is_struct_field_safe(k), "{k:?} should be a safe field");
707
+ }
708
+ }
709
+
710
+ #[test]
711
+ fn is_struct_field_safe_rejects_injection_and_nonrawable() {
712
+ for k in [
713
+ // the #379 injection shapes: anything with punctuation/space/braces
714
+ "x: i32 } pub fn pwned() {} struct Z { pub y",
715
+ "a, b",
716
+ "0abc",
717
+ "has space",
718
+ "unicodé",
719
+ "",
720
+ // non-rawable keywords would become invalid `r#self` etc.
721
+ "self", "Self", "super", "crate",
722
+ // bare wildcard is not a legal field name
723
+ "_",
724
+ ] {
725
+ assert!(!is_struct_field_safe(k), "{k:?} must NOT be a safe field");
726
+ }
727
+ }
728
+
729
+ #[test]
730
+ fn field_ident_never_emits_non_identifier_for_unsafe_key() {
731
+ // The core RCE guarantee: whatever key comes in, field_ident() out is ALWAYS a legal Rust
732
+ // identifier (ASCII ident chars, or a leading `r#`) — never verbatim injectable text.
733
+ let malicious = "x: i32 } pub fn PWNED() -> i32 { 1337 } struct S { pub y";
734
+ let out = field_ident(malicious);
735
+ assert!(out.starts_with("__tish_field_"), "unsafe key must be sanitized, got {out:?}");
736
+ let body = out.trim_start_matches("r#");
737
+ assert!(
738
+ body.chars().enumerate().all(|(i, c)| {
739
+ if i == 0 { c == '_' || c.is_ascii_alphabetic() } else { c == '_' || c.is_ascii_alphanumeric() }
740
+ }),
741
+ "field_ident output {out:?} is not a legal Rust identifier"
742
+ );
743
+ // deterministic: the def, the accessor, and the serializer must agree.
744
+ assert_eq!(field_ident(malicious), field_ident(malicious));
745
+ }
746
+
747
+ #[test]
748
+ fn field_ident_preserves_legit_and_raws_keywords() {
749
+ assert_eq!(field_ident("randomNumber"), "randomNumber");
750
+ assert_eq!(field_ident("x"), "x");
751
+ assert_eq!(field_ident("type"), "r#type");
752
+ // a non-rawable keyword is sanitized, NOT emitted as the invalid `r#self`.
753
+ assert!(field_ident("self").starts_with("__tish_field_"));
754
+ }
755
+
756
+ #[test]
757
+ fn annotation_object_with_unsafe_key_stays_boxed() {
758
+ // A `type` alias whose object shape has a non-identifier key must fall back to boxed Value,
759
+ // never a native struct with an injected field name.
760
+ let obj = TypeAnnotation::Object(vec![
761
+ ("safe".into(), TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default())),
762
+ (
763
+ "evil } fn pwned() {".into(),
764
+ TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default()),
765
+ ),
766
+ ]);
767
+ assert_eq!(RustType::from_annotation(&obj), RustType::Value);
768
+ }
769
+
574
770
  #[test]
575
771
  fn test_nullable_type() {
576
772
  let nullable = TypeAnnotation::Union(vec![
@@ -0,0 +1,21 @@
1
+ //! Dump perf fixtures to target/dump_*.rs for inspection.
2
+ use std::path::PathBuf;
3
+ use tishlang_compile::compile_project_full;
4
+
5
+ fn compile(rel: &str) -> String {
6
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
8
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
9
+ rust
10
+ }
11
+
12
+ #[test]
13
+ fn dump_perf_fixtures() {
14
+ let out = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target");
15
+ std::fs::write(out.join("dump_mandelbrot.rs"), compile("tests/perf/mandelbrot.tish")).unwrap();
16
+ std::fs::write(out.join("dump_fasta.rs"), compile("tests/perf/fasta.tish")).unwrap();
17
+ std::fs::write(out.join("dump_fannkuch.rs"), compile("tests/perf/fannkuch.tish")).unwrap();
18
+ std::fs::write(out.join("dump_fnv_hash.rs"), compile("tests/perf/fnv_hash.tish")).unwrap();
19
+ std::fs::write(out.join("dump_spectral_norm.rs"), compile("tests/perf/spectral_norm.tish")).unwrap();
20
+ std::fs::write(out.join("dump_queens.rs"), compile("tests/perf/queens.tish")).unwrap();
21
+ }
@@ -11,17 +11,6 @@ use tishlang_parser::parse;
11
11
 
12
12
  /// Enable every dark-shipped typed-native flag, matching `scripts/run_perf_gauntlet.sh`.
13
13
  fn enable_typed_flags() {
14
- for k in [
15
- "TISH_PARAM_NATIVE",
16
- "TISH_PARAM_INFER",
17
- "TISH_NATIVE_FN",
18
- "TISH_STRUCT_INFER",
19
- "TISH_FUSED_HOF",
20
- "TISH_NATIVE_HOF",
21
- "TISH_AGGREGATE_INFER",
22
- ] {
23
- std::env::set_var(k, "1");
24
- }
25
14
  }
26
15
 
27
16
  fn compile_typed(src: &str) -> String {
@@ -130,10 +119,12 @@ fn issue_173_adversarial_cases_do_not_fuse() {
130
119
  !rust.contains("repeat((i * 2"),
131
120
  "non-constant push arg must not be fused into a repeat-fill"
132
121
  );
133
- // Exactly the three sound fills are emitted (8, n, 3) — no extra fusions crept in.
134
- let fusions = rust.matches("std::iter::repeat").count();
135
- assert_eq!(
136
- fusions, 3,
137
- "exactly 3 fill loops should fuse (boolFill=8, sieve=n, oobGrow=3), found {fusions}"
138
- );
122
+ // The three canonical fills DO fuse (boolFill=8, sieve=n, oobGrow=3); the adversarial cases keep
123
+ // their push loops. Assert each sound fill is present rather than a brittle exact total: with the
124
+ // typing stack on by default, a fn that has a native `_nv` form (e.g. `numFillSieve`) may emit its
125
+ // fill in both the native and the boxed closure form, so the same fill can legitimately appear
126
+ // more than once.
127
+ assert!(rust.contains("repeat(true).take((8"), "boolFill (8) should fuse");
128
+ assert!(rust.contains("repeat(1_f64).take((n"), "sieve (n) should fuse");
129
+ assert!(rust.contains("repeat(1_f64).take((3"), "oobGrow (3) should fuse");
139
130
  }
@@ -0,0 +1,61 @@
1
+ //! Generated-Rust assertions for #173 part 3 — in-bounds index elision.
2
+ //!
3
+ //! When a native `Vec` is filled to a fixed length and an index is proven `< len` (an enclosing
4
+ //! `i < len` guard) and `>= 0`, the store drops its OOB-growth `resize` branch and the read drops its
5
+ //! `.get().unwrap_or(..)` branch — a direct `a[i]` like V8/Bun emit after range-proving a loop. Any
6
+ //! array that can't be proven (escapes, is reassigned, or the index isn't a guarded counter) keeps
7
+ //! the OOB-safe lowering. Soundness across backends is covered by the `tests/core` parity corpus.
8
+
9
+ use std::path::PathBuf;
10
+
11
+ use tishlang_compile::compile_project_full;
12
+
13
+ fn enable_typed_flags() {
14
+ }
15
+
16
+ fn compile_fixture_typed(rel: &str) -> String {
17
+ enable_typed_flags();
18
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
19
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
20
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
21
+ rust
22
+ }
23
+
24
+ #[test]
25
+ fn nsieve_inbounds_store_and_read_elide_their_guards() {
26
+ // `isPrime` is filled to length `n` and only indexed; the read `isPrime[i]` (guard `i < n`) and
27
+ // the strided store `isPrime[k] = false` (guard `k < n`, `k` non-negative, stored before its own
28
+ // `k = k + i` reassignment) are both provably in-bounds.
29
+ let rust = compile_fixture_typed("tests/perf/nsieve.tish");
30
+ // Read: direct index, NOT `.get(..).copied().unwrap_or(false)`.
31
+ assert!(
32
+ rust.contains("isPrime[(i) as usize]"),
33
+ "in-bounds read should be a direct index:\n{}",
34
+ rust.lines().filter(|l| l.contains("isPrime")).take(8).collect::<Vec<_>>().join("\n")
35
+ );
36
+ // Store: direct index, NO `resize` grow branch. The strided `isPrime[k] = false` is a discarded
37
+ // statement, so it lowers to the bare side-effect form (no `{ …; Value::Null }` value wrapper);
38
+ // the point is it is a direct indexed store, not a resize-grow.
39
+ assert!(
40
+ rust.contains("isPrime[(k) as usize] = false;"),
41
+ "in-bounds store should be a direct indexed store (no resize-grow branch):\n{}",
42
+ rust.lines().filter(|l| l.contains("isPrime")).take(8).collect::<Vec<_>>().join("\n")
43
+ );
44
+ assert!(
45
+ !rust.contains("isPrime.resize") && !rust.contains("isPrime.get("),
46
+ "the proven-fixed-length isPrime must have no resize/get fallbacks left:\n{}",
47
+ rust.lines().filter(|l| l.contains("isPrime")).take(8).collect::<Vec<_>>().join("\n")
48
+ );
49
+ }
50
+
51
+ #[test]
52
+ fn escaping_array_keeps_oob_safe_lowering() {
53
+ // `a` is passed to a function, so it could be mutated/shrunk out of line — the fixed-length fact
54
+ // must NOT apply and the store must keep its OOB-growth `resize` branch.
55
+ let rust = compile_fixture_typed("tests/core/inbounds_index.tish");
56
+ assert!(
57
+ rust.contains("resize"),
58
+ "an escaping array must retain the OOB-safe resize store:\n{}",
59
+ rust.lines().filter(|l| l.contains("resize") || l.contains("esc")).take(8).collect::<Vec<_>>().join("\n")
60
+ );
61
+ }
@@ -0,0 +1,160 @@
1
+ //! Generated-Rust assertions for the #174 Int32/U32 range-lattice round-trip erasure.
2
+ //!
3
+ //! #174 erases redundant `tishlang_runtime::to_int32` / `to_uint32` calls in the typed bitwise/shift
4
+ //! emitter when an operand is PROVABLY a finite integer — either an integer literal (a compile-time
5
+ //! constant) or a value the integer-range lattice bounds within `(-2^53, 2^53)`. The guarded
6
+ //! `to_int32` (NaN/±Infinity → 0) is retained for every unproven leaf, so the lowering is purely
7
+ //! additive and behaviour-identical (the gauntlet's `typed == boxed == node` checksum is the gate).
8
+ //!
9
+ //! These run in their own test binary so any env flags set here never leak into other tests.
10
+
11
+ use tishlang_compile::compile;
12
+ use tishlang_parser::parse;
13
+
14
+ fn enable_typed_flags() {
15
+ }
16
+
17
+ /// FNV-style hash loop: the canonical bitwise hot loop. The accumulator `h` lives in an i32 register
18
+ /// (existing lowering); #174 additionally folds the constant mask / shift counts and keeps the
19
+ /// `(h * C) >>> 0` f64-rounding excursion intact.
20
+ const FNV: &str = r#"
21
+ let h = 2166136261
22
+ for (let i = 0; i < 100; i++) {
23
+ h = h ^ (i & 255)
24
+ h = (h * 16777619) >>> 0
25
+ h = ((h << 13) | (h >>> 19)) >>> 0
26
+ }
27
+ let check = h >>> 0
28
+ console.log(check)
29
+ "#;
30
+
31
+ #[test]
32
+ fn issue_174_constant_mask_and_shift_counts_fold() {
33
+ // `i & 255`, `h << 13`, `h >>> 19`, `… >>> 0`: every integer-literal operand is a compile-time
34
+ // ToInt32 constant, so it must emit as a bare `i32` literal — NOT a runtime `to_int32(255_f64)`.
35
+ let rust = compile(&parse(FNV).unwrap()).unwrap();
36
+ assert!(
37
+ rust.contains("255i32"),
38
+ "the `& 255` mask must fold to an i32 constant:\n{}",
39
+ rust.lines().filter(|l| l.contains("255")).take(4).collect::<Vec<_>>().join("\n")
40
+ );
41
+ assert!(
42
+ rust.contains("13i32") && rust.contains("19i32"),
43
+ "shift counts 13 / 19 must fold to i32 constants"
44
+ );
45
+ // No runtime ToInt32/ToUint32 call on an integer LITERAL remains.
46
+ assert!(
47
+ !rust.contains("to_int32(255") && !rust.contains("to_uint32(13")
48
+ && !rust.contains("to_uint32(19") && !rust.contains("to_uint32(0"),
49
+ "integer-literal operands must not round-trip through a runtime to_int32/to_uint32 call:\n{}",
50
+ rust.lines().filter(|l| l.contains("to_uint32(") || l.contains("to_int32(")).take(8).collect::<Vec<_>>().join("\n")
51
+ );
52
+ }
53
+
54
+ #[test]
55
+ fn issue_174_fnv_accumulator_still_i32_register_no_regression() {
56
+ // The i32-register accumulator lowering (and its `(h * C)` f64-rounding excursion) is preserved:
57
+ // `h` is an i32 register, there is no per-op `to_int32(h)` round-trip, and the multiply excursion
58
+ // lowers to an unchecked f64 truncation. (Mirror of `fnv_accumulator_lowers_to_i32_register`.)
59
+ let rust = compile(&parse(FNV).unwrap()).unwrap();
60
+ assert!(
61
+ rust.contains("let mut h: i32 = (2166136261u32) as i32;"),
62
+ "h must stay an i32 register seeded via u32 reinterpretation"
63
+ );
64
+ assert!(
65
+ !rust.contains("to_int32(h)"),
66
+ "no per-op to_int32(h) round-trip on the i32 accumulator"
67
+ );
68
+ assert!(
69
+ rust.contains(".to_int_unchecked::<i64>()") && rust.contains("16777619"),
70
+ "the `h * 16777619` excursion stays an f64 multiply with an unchecked truncation"
71
+ );
72
+ }
73
+
74
+ #[test]
75
+ fn issue_174_range_proven_counter_drops_is_finite_guard() {
76
+ // A loop counter masked into a bitwise op is range-proven integral, so its ToInt32 must not
77
+ // round-trip through the guarded `to_int32(i)` call. The guard-free lowering is the saturating
78
+ // `(i) as i64 as i32` cast — #380 (PR #387) replaced the original `to_int_unchecked::<i64>()`
79
+ // here because `int_valued_locals` proves integrality but not magnitude, making the unchecked
80
+ // truncation UB on an overflowing accumulator; the saturating cast is defined for all f64 at
81
+ // the same hot-path cost (fnv_hash/fannkuch/mandelbrot measured at baseline in #387).
82
+ let src = r#"
83
+ let acc = 0
84
+ for (let i = 0; i < 64; i = i + 1) {
85
+ acc = acc ^ (i & 15)
86
+ }
87
+ console.log(acc >>> 0)
88
+ "#;
89
+ let rust = compile(&parse(src).unwrap()).unwrap();
90
+ assert!(
91
+ rust.contains("as i64 as i32"),
92
+ "a range-proven counter `i` in `i & 15` must lower to the guard-free saturating cast:\n{}",
93
+ rust.lines().filter(|l| l.contains("acc") || l.contains("to_int")).take(8).collect::<Vec<_>>().join("\n")
94
+ );
95
+ assert!(
96
+ !rust.contains("to_int32(i)"),
97
+ "a range-proven counter must not round-trip through the guarded to_int32 call:\n{}",
98
+ rust.lines().filter(|l| l.contains("to_int32(")).take(8).collect::<Vec<_>>().join("\n")
99
+ );
100
+ // Nothing in this program is the bounded `(h * C) >>> 0` excursion shape, so the UB-prone
101
+ // unchecked truncation must not appear at all (#380).
102
+ assert!(
103
+ !rust.contains("to_int_unchecked"),
104
+ "no unchecked truncation may appear for an unbounded-magnitude program"
105
+ );
106
+ }
107
+
108
+ #[test]
109
+ fn fnv_value_fn_uses_usize_for_loop() {
110
+ let src = r#"
111
+ function fnv1a(n) {
112
+ let h = 2166136261
113
+ for (let i = 0; i < n; i++) {
114
+ h = h ^ (i & 255)
115
+ h = (h * 16777619) >>> 0
116
+ h = ((h << 13) | (h >>> 19)) >>> 0
117
+ }
118
+ return h >>> 0
119
+ }
120
+ let check = fnv1a(100)
121
+ console.log(check)
122
+ "#;
123
+ enable_typed_flags();
124
+ let rust = compile(&parse(src).unwrap()).unwrap();
125
+ assert!(
126
+ rust.contains("for _usize_i_0 in 0..(n as usize)"),
127
+ "fnv1a hot loop should lower to a usize `for` over param n:\n{}",
128
+ rust.lines().filter(|l| l.contains("usize") || l.contains("fnv1a")).take(6).collect::<Vec<_>>().join("\n")
129
+ );
130
+ assert!(
131
+ rust.contains("let mut h: i32 = (2166136261u32) as i32;"),
132
+ "accumulator h must stay on the i32 register path"
133
+ );
134
+ }
135
+
136
+ #[test]
137
+ fn issue_174_unproven_leaf_keeps_guarded_to_int32() {
138
+ // SOUNDNESS: an f64 value that is NOT provably a finite integer (here a fractional accumulator —
139
+ // the range lattice drops it because `n = n + 0.5` is not integer-preserving) must KEEP the
140
+ // guarded `tishlang_runtime::to_int32`, never the unchecked truncation (a non-integer/NaN/±Inf
141
+ // input must map through the JS ToInt32 path, not a raw `to_int_unchecked`).
142
+ let src = r#"
143
+ let n = 0.0
144
+ for (let i = 0; i < 10; i = i + 1) { n = n + 0.5 }
145
+ let m = n & 7
146
+ console.log(m >>> 0)
147
+ "#;
148
+ let rust = compile(&parse(src).unwrap()).unwrap();
149
+ // `n` is a top-level numeric used only at top level, so per #313 it is a native `let mut n: f64`
150
+ // local (not a `thread_local Cell` — no function references it). The soundness invariant is
151
+ // unchanged: the unproven fractional value must STILL pass through the guarded `to_int32` (not an
152
+ // unchecked truncation) before the `& 7` mask.
153
+ assert!(
154
+ rust.contains("tishlang_runtime::to_int32(n) & 7i32"),
155
+ "an unproven f64 leaf must keep the guarded to_int32 before the mask:\n{}",
156
+ rust.lines().filter(|l| l.contains("& ") || l.contains("to_int")).take(6).collect::<Vec<_>>().join("\n")
157
+ );
158
+ // The literal `7` still folds; only the unproven `n` keeps the guard.
159
+ assert!(rust.contains("7i32"), "the literal mask `7` still folds to a constant");
160
+ }