tjs-lang 0.9.0 → 0.9.1

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.
@@ -0,0 +1,100 @@
1
+ # North star: JSON-Schema + predicates as the single source of truth for types
2
+
3
+ > **Status: strategic direction (2026-07-06, user-set). Possibly post-1.0. Use as
4
+ > a decision lens NOW.** Related: `$predicate` keyword
5
+ > (`src/lang/predicate-schema.ts`), `createPredicateEvaluator`, the `tjs-lang/css`
6
+ > library, `docs/ambient-contracts.md`, PRINCIPLES.md.
7
+
8
+ ## The principle
9
+
10
+ **A TJS type is, canonically, a JSON-Schema node — optionally carrying a
11
+ `$predicate` (a verified-pure predicate cluster) for the computational part that
12
+ plain JSON-Schema can't express.** Everything else — examples-as-types (`x: 0`),
13
+ TS-derived types, `Type`/`Generic` declarations, `TypeDescriptor` — is **surface
14
+ syntax or an internal projection** of that one canonical form, not a competing
15
+ source of truth.
16
+
17
+ - **Structure** (shape, required keys, enums, ranges, nesting) → JSON-Schema
18
+ keywords, which any validator understands.
19
+ - **Computation** (open value grammars, cross-field invariants, recursive
20
+ grammars — the things TS/JSON-Schema cave to `string`/`any` on) → `$predicate`,
21
+ which a predicate-aware validator runs.
22
+
23
+ Together they express the full range of what TJS types mean, in a **standard,
24
+ serializable, inspectable, cross-language** form.
25
+
26
+ ## The decision lens
27
+
28
+ For any architecture or implementation choice, ask:
29
+
30
+ > **Does this move types toward — or away from — being fully expressible as
31
+ > JSON-Schema + `$predicate`?**
32
+
33
+ Concretely, *toward* looks like:
34
+
35
+ - New type capabilities are added by (a) a JSON-Schema keyword, or (b) a
36
+ predicate — never a bespoke `TypeDescriptor` field that can't round-trip to
37
+ JSON-Schema + `$predicate`.
38
+ - `TypeDescriptor` is treated as a **lossless projection** of the canonical form
39
+ (a convenient in-memory shape), not a superset. If something can live in
40
+ `TypeDescriptor` but not in JSON-Schema + `$predicate`, that's a smell.
41
+ - `.d.ts` emission, runtime validation, inference, and autocomplete all **derive
42
+ from** the canonical form rather than from a parallel representation.
43
+ - The predicate subset stays **small and portable** (see below) so `$predicate`
44
+ can run anywhere — expanding it is a cost, not a free win.
45
+
46
+ *Away* looks like: a TJS-only internal type IR that accretes features JSON-Schema
47
+ + `$predicate` can't carry; validation logic that only the JS runtime can do;
48
+ predicate features that need a full JS engine.
49
+
50
+ ## Why this is the right endgame
51
+
52
+ - **Types as data.** JSON-Schema + `$predicate` is just JSON — it travels, it's
53
+ inspectable, it's diffable, it survives a network hop or a file. Types stop
54
+ being a compile-time-only artifact.
55
+ - **Cross-language by construction.** Any language with a JSON-Schema validator
56
+ gets TJS *structure* for free; add a small predicate VM (below) and it gets the
57
+ *computational* half too. TJS types become a contract multiple runtimes share.
58
+ - **Standard, not bespoke.** We ride the JSON-Schema ecosystem (tooling, docs,
59
+ editors) instead of reinventing it, and add exactly the one thing it lacks.
60
+ - **It's already real.** `tjs-lang/css` is a working JSON-Schema + `$predicate`
61
+ artifact; `tosijs-schema` (via `createPredicateEvaluator`) already runs it;
62
+ `cssStyleSchema()` produces exactly this shape. The endgame exists in miniature.
63
+
64
+ ## The priority that unlocks it: a small, portable predicate VM
65
+
66
+ For "types across language boundaries" to be real, `$predicate` must **run
67
+ anywhere**, not just in JS. The priority is a **reference implementation of a tiny,
68
+ portable VM that safely evaluates the predicate subset** — ideally smaller than a
69
+ JS runtime, implementable in a few hundred lines in any language.
70
+
71
+ It's tractable precisely because the predicate subset is deliberately minimal
72
+ (the verifier enforces it): pure, synchronous, **no loops** (recursion + array
73
+ methods only), fuel-bounded, no IO, a whitelist of pure operations (member access,
74
+ comparisons, `&&`/`||`/ternary, `typeof`, a fixed set of string/array/Math/regex
75
+ methods). That's a small tree-walking interpreter — no closures-over-mutable-state,
76
+ no async, no allocation surprises, no host access.
77
+
78
+ **Key architectural implication — carry the AST, not (only) the source.** Today
79
+ `$predicate` is predicate *source* (JS/AJS text). A portable VM in Rust/Go/Python
80
+ shouldn't have to embed a JS parser. So the canonical portable form of a predicate
81
+ should be its **serialized AST** (JSON) — which is the original AJS thesis ("code
82
+ travels as data") applied here. Source stays the authoring form; the serialized
83
+ AST is the wire/exec form the small VM walks. A `$predicate` could carry either,
84
+ with the AST as the portable default.
85
+
86
+ ## Open questions
87
+
88
+ - **`TypeDescriptor`'s fate:** keep it as a cached projection of JSON-Schema +
89
+ `$predicate`, or eventually retire it? At minimum, guard that it can't express
90
+ what the canonical form can't.
91
+ - **The predicate ISA:** pin down the exact minimal node set + operation whitelist
92
+ the reference VM must support (a spec), and a **conformance suite** every port
93
+ runs — so "TJS types" means the same thing in every language.
94
+ - **AST format:** settle the serialized predicate AST schema (it should itself be
95
+ describable — turtles: the AST format as a JSON-Schema).
96
+ - **Fuel across languages:** the fuel model must be spec'd (per-node cost) so a
97
+ runaway predicate is bounded identically everywhere.
98
+ - **Structure/predicate split:** conventions for what belongs in JSON-Schema
99
+ keywords vs `$predicate` (prefer standard keywords; reach for `$predicate` only
100
+ for the genuinely computational).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tjs-lang",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Type-safe JavaScript dialect with runtime validation, sandboxed VM execution, and AI agent orchestration. Transpiles TypeScript to validated JS with fuel-metered execution for untrusted code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -19,6 +19,8 @@ export function generateWasmBootstrap(blocks: WasmBlock[]): {
19
19
  error?: string
20
20
  byteLength?: number
21
21
  }[]
22
+ /** Compile-time lints (e.g. i32/i32 integer-division) across all blocks. */
23
+ warnings: string[]
22
24
  } {
23
25
  const compiled = compileBlocksToModule(blocks)
24
26
 
@@ -40,7 +42,7 @@ export function generateWasmBootstrap(blocks: WasmBlock[]): {
40
42
  })
41
43
 
42
44
  if (compiled.exports.length === 0) {
43
- return { code: '', results }
45
+ return { code: '', results, warnings: compiled.warnings }
44
46
  }
45
47
 
46
48
  // WAT comment block — one section per included function
@@ -72,7 +74,9 @@ export function generateWasmBootstrap(blocks: WasmBlock[]): {
72
74
  const anyNeedsMemory = compiled.needsMemory
73
75
 
74
76
  const code = `${watComments}
75
- ;(async()=>{
77
+ ;(globalThis.__tjs_wasm_pending??=[]);
78
+ globalThis.__tjs_wasm_ready??=(()=>Promise.all(globalThis.__tjs_wasm_pending));
79
+ globalThis.__tjs_wasm_pending.push((async()=>{
76
80
  const __wasmExports=[${exportData}];
77
81
  const __wasmModuleB64=${JSON.stringify(moduleBase64)};
78
82
  const __b64ToBytes=s=>{const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a};
@@ -105,11 +109,11 @@ for(const{id,n,c,m}of __wasmExports){
105
109
  if(a.buffer===__wasmMem.buffer) continue;
106
110
  const ab=new Uint8Array(a.buffer,a.byteOffset,a.byteLength);off=(off+15)&~15;ab.set(mv.slice(off,off+ab.length));off+=ab.length}}
107
111
  return r};
108
- }})();
112
+ }})().catch(()=>{}));
109
113
  `.trim()
110
114
 
111
115
  // Strip the temporary _exportName field before returning to caller.
112
116
  const publicResults = results.map(({ _exportName: _, ...rest }) => rest)
113
117
 
114
- return { code, results: publicResults }
118
+ return { code, results: publicResults, warnings: compiled.warnings }
115
119
  }
@@ -682,18 +682,26 @@ export function transpileToJS(
682
682
 
683
683
  // Mirror unverifiable Type/Generic predicates into `warnings` (they still
684
684
  // compile — as plain functions — but aren't fuel-bounded / safe on untrusted
685
- // data). The full per-predicate status is returned as `predicates`.
686
- for (const p of preprocessed.predicates) {
687
- if (!p.verified) {
688
- warnings.push(
689
- `${p.kind} '${
690
- p.name
691
- }': predicate is not verifiable, compiled as a plain function (not fuel-bounded, not safe on untrusted data)${
692
- p.reason ? ` ${p.reason.replace(/\s+/g, ' ').trim()}` : ''
693
- }`
694
- )
695
- }
685
+ // data). The full per-predicate status is returned as `predicates`. Under the
686
+ // explicit `TjsStrict` opt-in these escalate to a transpile error (the subset
687
+ // invariant: warn by default, error only when the author asked for strict).
688
+ const unverified = preprocessed.predicates.filter((p) => !p.verified)
689
+ const predicateMsg = (p: PredicateVerification) =>
690
+ `${p.kind} '${
691
+ p.name
692
+ }': predicate is not verifiable, compiled as a plain function (not fuel-bounded, not safe on untrusted data)${
693
+ p.reason ? ` — ${p.reason.replace(/\s+/g, ' ').trim()}` : ''
694
+ }`
695
+ if (unverified.length && preprocessed.tjsModes.tjsStrict) {
696
+ throw new Error(
697
+ `TjsStrict: ${
698
+ unverified.length
699
+ } predicate(s) could not be verified:\n${unverified
700
+ .map((p) => ` ${predicateMsg(p)}`)
701
+ .join('\n')}`
702
+ )
696
703
  }
704
+ for (const p of unverified) warnings.push(predicateMsg(p))
697
705
  const predicateReport = preprocessed.predicates.length
698
706
  ? preprocessed.predicates
699
707
  : undefined
@@ -1202,6 +1210,23 @@ export function transpileToJS(
1202
1210
  code = wasmBootstrap.code + '\n' + code
1203
1211
  }
1204
1212
  wasmCompiled = wasmBootstrap.results
1213
+ // Surface WASM compile failures as warnings (they were only in
1214
+ // `wasmCompiled` before, so a `wasm{}` block that can't compile fell back to
1215
+ // its `fallback{}` SILENTLY — the worst failure mode for a perf feature).
1216
+ // The full status stays on `wasmCompiled`; this just makes it visible.
1217
+ for (const w of wasmCompiled) {
1218
+ if (!w.success) {
1219
+ warnings.push(
1220
+ `wasm{} block '${
1221
+ w.id
1222
+ }' did not compile — running the fallback{} (JS)${
1223
+ w.error ? `: ${w.error}` : ''
1224
+ }`
1225
+ )
1226
+ }
1227
+ }
1228
+ // Compile-time wasm lints (e.g. i32/i32 integer division — UI-#4).
1229
+ for (const w of wasmBootstrap.warnings) warnings.push(`wasm{}: ${w}`)
1205
1230
  }
1206
1231
 
1207
1232
  return {
@@ -260,7 +260,10 @@ export function extractWasmBlocks(source: string): {
260
260
  ? `globalThis.${block.id}(${captureArgs})`
261
261
  : `globalThis.${block.id}()`
262
262
 
263
- const dispatch = `(globalThis.${block.id} ? ${wasmCall} : (() => {${fallbackCode}})())`
263
+ // `globalThis.__tjs_wasm_enabled === false` forces the fallback path even
264
+ // when the WASM is instantiated — a public toggle for A/B benchmarking
265
+ // (WASM vs JS) without poking the internal `__tjs_wasm_<id>` globals (UI-#3).
266
+ const dispatch = `((globalThis.__tjs_wasm_enabled !== false && globalThis.${block.id}) ? ${wasmCall} : (() => {${fallbackCode}})())`
264
267
 
265
268
  result += dispatch
266
269
  i = matchEnd
@@ -196,6 +196,13 @@ export interface TjsModes {
196
196
  tjsNoVar: boolean
197
197
  /** TjsSafeAssign: let declarations need an initializer or `: example` annotation; literal undefined/null/void 0 assigned to typed lets is flagged */
198
198
  tjsSafeAssign: boolean
199
+ /**
200
+ * TjsStrict: the author explicitly opted into strict semantics (via the
201
+ * `TjsStrict` directive). Distinct from "all modes on" (native TJS's default) —
202
+ * only `true` when the directive is present. Escalates soft diagnostics (e.g. an
203
+ * unverifiable `Type`/`Generic` predicate) from a warning to a transpile error.
204
+ */
205
+ tjsStrict: boolean
199
206
  }
200
207
 
201
208
  /**
@@ -158,6 +158,7 @@ export function preprocess(
158
158
  tjsSafeEval: false,
159
159
  tjsNoVar: false,
160
160
  tjsSafeAssign: false,
161
+ tjsStrict: false,
161
162
  }
162
163
  : {
163
164
  tjsEquals: true,
@@ -168,6 +169,10 @@ export function preprocess(
168
169
  tjsSafeEval: false, // opt-in only (adds import)
169
170
  tjsNoVar: true,
170
171
  tjsSafeAssign: true,
172
+ // Native TJS has all modes on by default, but is NOT "strict" unless the
173
+ // author writes the `TjsStrict` directive — that opt-in is what escalates
174
+ // e.g. unverifiable predicates from a warning to a hard error.
175
+ tjsStrict: false,
171
176
  }
172
177
 
173
178
  // Safety: native TJS defaults to 'inputs' (runtime default),
@@ -203,7 +208,8 @@ export function preprocess(
203
208
  const directive = match[2]
204
209
 
205
210
  if (directive === 'TjsStrict') {
206
- // Enable all TJS modes
211
+ // Enable all TJS modes + mark strict (the author's explicit opt-in, which
212
+ // escalates soft diagnostics like unverifiable predicates to hard errors).
207
213
  tjsModes.tjsEquals = true
208
214
  tjsModes.tjsClass = true
209
215
  tjsModes.tjsDate = true
@@ -211,6 +217,7 @@ export function preprocess(
211
217
  tjsModes.tjsNoVar = true
212
218
  tjsModes.tjsStandard = true
213
219
  tjsModes.tjsSafeAssign = true
220
+ tjsModes.tjsStrict = true
214
221
  } else if (directive === 'TjsCompat') {
215
222
  // Disable all TJS modes (JS-compatible)
216
223
  tjsModes.tjsEquals = false
@@ -519,6 +526,7 @@ export function parse(
519
526
  tjsSafeEval: false,
520
527
  tjsNoVar: false,
521
528
  tjsSafeAssign: false,
529
+ tjsStrict: false,
522
530
  } as TjsModes,
523
531
  }
524
532
 
@@ -52,3 +52,27 @@ describe('predicate verification is reported on the transpile result', () => {
52
52
  expect(tjs('function f() { return 1 }').predicates).toBeUndefined()
53
53
  })
54
54
  })
55
+
56
+ describe('TjsStrict escalates an unverifiable predicate to a transpile error', () => {
57
+ const bad =
58
+ "Type Bad 'bad' { predicate(xs) { for (const x of xs) {} return true } }"
59
+
60
+ it('warns (does not throw) without the directive', () => {
61
+ const r = tjs(bad)
62
+ expect(r.predicates?.[0]?.verified).toBe(false)
63
+ expect(r.warnings?.length).toBeGreaterThan(0)
64
+ })
65
+
66
+ it('throws under TjsStrict, naming the predicate + reason', () => {
67
+ expect(() => tjs('TjsStrict\n' + bad)).toThrow(
68
+ /TjsStrict.*could not be verified/s
69
+ )
70
+ expect(() => tjs('TjsStrict\n' + bad)).toThrow(/Bad/)
71
+ })
72
+
73
+ it('does NOT throw under TjsStrict when the predicate verifies', () => {
74
+ const r = tjs("TjsStrict\nType Pos 'p' { predicate(x) { return x > 0 } }")
75
+ expect(r.predicates?.[0]?.verified).toBe(true)
76
+ expect(r.warnings).toBeUndefined()
77
+ })
78
+ })
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A `wasm{}` block that can't compile falls back to its `fallback{}` (JS) — which
3
+ * used to happen SILENTLY (the failure was only on `result.wasmCompiled`, which
4
+ * consumers don't inspect), so WASM looked like it "worked" when it was running
5
+ * the JS fallback. Now the failure is also mirrored into `result.warnings`.
6
+ * (tosijs-ui feedback UI-#1.)
7
+ */
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { tjs } from './index'
10
+
11
+ const UNSUPPORTED = `function fill(out: [0.0], w: 0, h: 0) {
12
+ wasm {
13
+ for (let y = 0; y < h; y += 1) {
14
+ for (let x = 0; x < w; x += 1) { out[y * w + x] = 1.0 }
15
+ }
16
+ } fallback {
17
+ for (let i = 0; i < w * h; i++) out[i] = 0
18
+ }
19
+ return out
20
+ }`
21
+
22
+ const SUPPORTED = `function scale(arr: [0.0], len: 0, factor: 0.0) {
23
+ wasm {
24
+ for (let i = 0; i < len; i += 4) {
25
+ let off = i * 4
26
+ f32x4_store(arr, off, f32x4_mul(f32x4_load(arr, off), f32x4_splat(factor)))
27
+ }
28
+ } fallback {
29
+ for (let i = 0; i < len; i++) arr[i] *= factor
30
+ }
31
+ return arr
32
+ }`
33
+
34
+ describe('silent wasm{} fallback is now surfaced as a warning', () => {
35
+ it('warns (with the reason) when a block cannot compile', () => {
36
+ const r = tjs(UNSUPPORTED)
37
+ expect(r.wasmCompiled?.[0]?.success).toBe(false) // signal already existed…
38
+ const w = r.warnings?.find((w) =>
39
+ /wasm\{\} block .* did not compile/.test(w)
40
+ )
41
+ expect(w).toBeTruthy() // …now it's in warnings too
42
+ expect(w).toMatch(/fallback/)
43
+ })
44
+
45
+ it('does not warn when the block compiles to WASM', () => {
46
+ const r = tjs(SUPPORTED)
47
+ expect(r.wasmCompiled?.[0]?.success).toBe(true)
48
+ expect(r.warnings?.some((w) => /wasm\{\}/.test(w)) ?? false).toBe(false)
49
+ })
50
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Lint the i32/i32 integer-division footgun inside `wasm{}`: `/` with two integer
3
+ * operands truncates, and coercion to f64 only happens at the *next* operator, so
4
+ * `x / w - 0.5` is silently `0 - 0.5` for all `x < w` — it silently broke a real
5
+ * Mandelbrot kernel (tosijs-ui UI-#4). Now it's surfaced in `result.warnings`.
6
+ */
7
+ import { describe, it, expect } from 'bun:test'
8
+ import { tjs } from './index'
9
+
10
+ const intDivWarning = (r: { warnings?: string[] }) =>
11
+ r.warnings?.find((w) => /integer division/.test(w))
12
+
13
+ describe('i32/i32 division lint', () => {
14
+ it('warns when both operands are i32 (loop vars)', () => {
15
+ const r = tjs(`function f(n: 0) {
16
+ wasm {
17
+ for (let y = 1; y < n; y += 1) {
18
+ for (let x = 1; x < n; x += 1) { let r = x / y }
19
+ }
20
+ } fallback { }
21
+ return n
22
+ }`)
23
+ expect(r.wasmCompiled?.[0]?.success).toBe(true) // block compiled…
24
+ expect(intDivWarning(r)).toBeTruthy() // …and the footgun was flagged
25
+ expect(intDivWarning(r)).toMatch(/\+ 0\.0/) // suggests the fix
26
+ })
27
+
28
+ it('warns once per block, not per occurrence', () => {
29
+ const r = tjs(`function f(n: 0) {
30
+ wasm {
31
+ for (let i = 1; i < n; i += 1) { let a = i / n; let b = n / i; let c = i / i }
32
+ } fallback { }
33
+ return n
34
+ }`)
35
+ expect(r.warnings?.filter((w) => /integer division/.test(w)).length).toBe(1)
36
+ })
37
+
38
+ it('does NOT warn on float division', () => {
39
+ const r = tjs(`function g(a: 0.0, b: 0.0) {
40
+ wasm { let r = a / b } fallback { }
41
+ return a
42
+ }`)
43
+ expect(intDivWarning(r)).toBeUndefined()
44
+ })
45
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `globalThis.__tjs_wasm_ready()` — an awaitable signal for WASM instantiation.
3
+ *
4
+ * The emitted bootstrap instantiates WASM asynchronously (fire-and-forget), so
5
+ * synchronous code right after transpile+eval used to hit the JS `fallback{}`
6
+ * because `globalThis.__tjs_wasm_N` wasn't set yet — with no way to await it
7
+ * (tosijs-ui feedback UI-#2, "bit us hard"). Now each module pushes its
8
+ * instantiation promise onto `globalThis.__tjs_wasm_pending` and
9
+ * `__tjs_wasm_ready()` awaits them all.
10
+ */
11
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
12
+ import { tjs } from './index'
13
+ import { createRuntime } from './runtime'
14
+
15
+ const g = globalThis as any
16
+ g.__tjs = createRuntime()
17
+
18
+ // The `__tjs_wasm_*` globals are shared process-wide, so other test files that
19
+ // eval a bootstrap can leave them set. Clear before each test so the
20
+ // "undefined before ready" assertion reflects THIS module's state.
21
+ const clearWasmGlobals = () => {
22
+ delete g.__tjs_wasm_pending
23
+ delete g.__tjs_wasm_ready
24
+ delete g.__tjs_wasm_0
25
+ }
26
+
27
+ const KERNEL = `function scale(arr: [0.0], len: 0, f: 0.0) {
28
+ wasm {
29
+ let s = f32x4_splat(f)
30
+ for (let i = 0; i < len; i += 4) { let o = i * 4; f32x4_store(arr, o, f32x4_mul(f32x4_load(arr, o), s)) }
31
+ } fallback { for (let i = 0; i < len; i++) arr[i] *= f }
32
+ return arr
33
+ }`
34
+
35
+ beforeEach(clearWasmGlobals)
36
+ afterEach(clearWasmGlobals)
37
+
38
+ describe('__tjs_wasm_ready', () => {
39
+ it('resolves once WASM is instantiated (was a fallback race before)', async () => {
40
+ const { code } = tjs(KERNEL)
41
+ new Function(code)() // run the bootstrap (fire-and-forget instantiation)
42
+
43
+ expect(typeof g.__tjs_wasm_ready).toBe('function')
44
+ // synchronously right after eval, the module isn't ready yet…
45
+ expect(g.__tjs_wasm_0).toBeUndefined()
46
+
47
+ await g.__tjs_wasm_ready()
48
+
49
+ // …now it is — a sync call here takes the WASM path, not the fallback.
50
+ expect(typeof g.__tjs_wasm_0).toBe('function')
51
+ })
52
+
53
+ it('accumulates pending across modules and awaits all', async () => {
54
+ new Function(tjs(KERNEL).code)()
55
+ const firstReady = g.__tjs_wasm_ready
56
+ new Function(tjs(KERNEL.replace('scale', 'scale2')).code)()
57
+ // same shared helper + a growing pending list, not overwritten per module
58
+ expect(g.__tjs_wasm_ready).toBe(firstReady)
59
+ expect(g.__tjs_wasm_pending.length).toBe(2)
60
+
61
+ await g.__tjs_wasm_ready()
62
+ expect(typeof g.__tjs_wasm_0).toBe('function')
63
+ })
64
+ })
65
+
66
+ describe('__tjs_wasm_enabled toggle (force the JS fallback for benchmarking)', () => {
67
+ // A scalar kernel so input validation passes (a Float32Array isn't
68
+ // `Array.isArray`, which would early-return before the dispatch). We spy on the
69
+ // compiled export to detect which path the dispatch takes.
70
+ const SCALAR = `function pick(x: 0.0) { wasm { return x } fallback { return x } }`
71
+
72
+ it('routes to the fallback when disabled, even though WASM is ready', async () => {
73
+ const pick = new Function(tjs(SCALAR).code + '\nreturn pick')()
74
+ await g.__tjs_wasm_ready()
75
+ const real = g.__tjs_wasm_0
76
+ let wasmCalls = 0
77
+ g.__tjs_wasm_0 = (...a: any[]) => {
78
+ wasmCalls++
79
+ return real(...a)
80
+ }
81
+
82
+ pick(1) // default (enabled) → WASM path
83
+ expect(wasmCalls).toBe(1)
84
+
85
+ g.__tjs_wasm_enabled = false
86
+ pick(1) // forced fallback → WASM not called
87
+ expect(wasmCalls).toBe(1)
88
+
89
+ g.__tjs_wasm_enabled = true
90
+ pick(1) // back to WASM
91
+ expect(wasmCalls).toBe(2)
92
+ delete g.__tjs_wasm_enabled
93
+ })
94
+ })
@@ -0,0 +1,84 @@
1
+ /**
2
+ * f32x4 min/max, lane comparisons (lt/le/gt/ge/eq/ne → mask), and select
3
+ * (branch-free blend) — the intrinsics that unlock DATA-DEPENDENT SIMD (clamp,
4
+ * saturate, SIMD Mandelbrot). Before this, f32x4 was arithmetic-only, so masked
5
+ * SIMD was impossible (tosijs-ui feedback UI-#6). Executed as real WASM and
6
+ * checked lane 0 via `extract_lane` (compares are checked through `select`, since
7
+ * a raw mask isn't a meaningful float).
8
+ */
9
+ import { describe, it, expect } from 'bun:test'
10
+ import { compileToWasm, instantiateWasm, type WasmBlock } from './wasm'
11
+
12
+ /** Compile+instantiate a 2-arg f32 kernel `compute(a, b)` from a block body. */
13
+ async function kernel(body: string, captures: string[]) {
14
+ const block: WasmBlock = { id: 'k', body, captures, start: 0, end: 0 }
15
+ const result = compileToWasm(block)
16
+ expect(result.error).toBeFalsy()
17
+ // SIMD modules import env.memory (for potential v128 load/store); provide one.
18
+ const memory = new WebAssembly.Memory({ initial: 16 })
19
+ const instance = await instantiateWasm(result.bytes, memory)
20
+ return instance.exports.compute as (...a: number[]) => number
21
+ }
22
+
23
+ describe('f32x4 min / max', () => {
24
+ it('max', async () => {
25
+ const f = await kernel(
26
+ 'return f32x4_extract_lane(f32x4_max(f32x4_splat(a), f32x4_splat(b)), 0)',
27
+ ['a', 'b']
28
+ )
29
+ expect(f(3, 5)).toBe(5)
30
+ expect(f(-2, -8)).toBe(-2)
31
+ expect(f(4.5, 4.5)).toBe(4.5)
32
+ })
33
+ it('min', async () => {
34
+ const f = await kernel(
35
+ 'return f32x4_extract_lane(f32x4_min(f32x4_splat(a), f32x4_splat(b)), 0)',
36
+ ['a', 'b']
37
+ )
38
+ expect(f(3, 5)).toBe(3)
39
+ expect(f(-2, -8)).toBe(-8)
40
+ })
41
+ })
42
+
43
+ describe('f32x4 select + comparisons (branch-free lane blend)', () => {
44
+ // select(cmp(a,b), 1, 2) → 1 when the comparison is true (mask lane all-1s), else 2
45
+ const via = (cmp: string) =>
46
+ kernel(
47
+ `return f32x4_extract_lane(f32x4_select(${cmp}(f32x4_splat(a), f32x4_splat(b)), f32x4_splat(1.0), f32x4_splat(2.0)), 0)`,
48
+ ['a', 'b']
49
+ )
50
+
51
+ it('gt', async () => {
52
+ const f = await via('f32x4_gt')
53
+ expect(f(5, 3)).toBe(1)
54
+ expect(f(3, 5)).toBe(2)
55
+ })
56
+ it('lt', async () => {
57
+ const f = await via('f32x4_lt')
58
+ expect(f(3, 5)).toBe(1)
59
+ expect(f(5, 3)).toBe(2)
60
+ })
61
+ it('ge / le at equality', async () => {
62
+ expect(await (await via('f32x4_ge'))(4, 4)).toBe(1)
63
+ expect(await (await via('f32x4_le'))(4, 4)).toBe(1)
64
+ expect(await (await via('f32x4_ge'))(3, 4)).toBe(2)
65
+ })
66
+ it('eq / ne', async () => {
67
+ expect(await (await via('f32x4_eq'))(4, 4)).toBe(1)
68
+ expect(await (await via('f32x4_eq'))(4, 5)).toBe(2)
69
+ expect(await (await via('f32x4_ne'))(4, 5)).toBe(1)
70
+ })
71
+ })
72
+
73
+ describe('clamp via min/max (the canonical use)', () => {
74
+ it('clamps to [lo, hi] on lane 0', async () => {
75
+ // clamp(v, lo, hi) = min(max(v, lo), hi); fix lo=0, hi=3 via constants
76
+ const f = await kernel(
77
+ 'return f32x4_extract_lane(f32x4_min(f32x4_max(f32x4_splat(a), f32x4_splat(0.0)), f32x4_splat(3.0)), 0)',
78
+ ['a', 'b']
79
+ )
80
+ expect(f(-5, 0)).toBe(0)
81
+ expect(f(1.5, 0)).toBe(1.5)
82
+ expect(f(10, 0)).toBe(3)
83
+ })
84
+ })