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.
- package/CLAUDE.md +7 -1
- package/dist/index.js +111 -107
- package/dist/index.js.map +3 -3
- package/dist/src/lang/emitters/js-wasm.d.ts +2 -0
- package/dist/src/lang/parser-types.d.ts +7 -0
- package/dist/tjs-browser-from-ts.js +1 -1
- package/dist/tjs-browser-from-ts.js.map +1 -1
- package/dist/tjs-browser.js +93 -89
- package/dist/tjs-browser.js.map +3 -3
- package/dist/tjs-eval.js +14 -14
- package/dist/tjs-eval.js.map +2 -2
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +1 -1
- package/dist/tjs-lang.js +94 -90
- package/dist/tjs-lang.js.map +3 -3
- package/dist/tjs-runtime.js +1 -1
- package/dist/tjs-runtime.js.map +1 -1
- package/dist/tjs-vm.js +2 -2
- package/dist/tjs-vm.js.map +2 -2
- package/docs/type-system-north-star.md +100 -0
- package/package.json +1 -1
- package/src/lang/emitters/js-wasm.ts +8 -4
- package/src/lang/emitters/js.ts +36 -11
- package/src/lang/parser-transforms.ts +4 -1
- package/src/lang/parser-types.ts +7 -0
- package/src/lang/parser.ts +9 -1
- package/src/lang/predicate-report.test.ts +24 -0
- package/src/lang/wasm-fallback-warning.test.ts +50 -0
- package/src/lang/wasm-intdiv-lint.test.ts +45 -0
- package/src/lang/wasm-ready.test.ts +94 -0
- package/src/lang/wasm-simd-ops.test.ts +84 -0
- package/src/lang/wasm.ts +61 -2
|
@@ -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.
|
|
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
|
-
;(
|
|
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
|
}
|
package/src/lang/emitters/js.ts
CHANGED
|
@@ -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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
package/src/lang/parser-types.ts
CHANGED
|
@@ -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
|
/**
|
package/src/lang/parser.ts
CHANGED
|
@@ -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
|
+
})
|