tjs-lang 0.8.3 → 0.8.5
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 +23 -1
- package/demo/docs.json +7 -7
- package/dist/experiments/predicates/css.predicates.d.ts +13 -0
- package/dist/experiments/predicates/verify.d.ts +39 -0
- package/dist/index.js +104 -100
- package/dist/index.js.map +4 -4
- package/dist/src/lang/browser-from-ts.d.ts +38 -0
- package/dist/src/lang/browser.d.ts +13 -0
- package/dist/src/lang/index.d.ts +2 -0
- package/dist/src/lang/predicate-schema.d.ts +39 -0
- package/dist/src/lang/predicate.d.ts +103 -0
- package/dist/src/lang/transpiler.d.ts +2 -0
- package/dist/src/lang/ts-cdn-shim.d.ts +2 -0
- package/dist/src/vm/runtime.d.ts +22 -0
- package/dist/tjs-browser-from-ts.js +58 -0
- package/dist/tjs-browser-from-ts.js.map +7 -0
- package/dist/tjs-browser.js +370 -0
- package/dist/tjs-browser.js.map +7 -0
- package/dist/tjs-eval.js +29 -29
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +86 -82
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +45 -45
- package/dist/tjs-vm.js.map +3 -3
- package/llms.txt +2 -0
- package/package.json +15 -1
- package/src/lang/browser-bundle.test.ts +69 -0
- package/src/lang/browser-from-ts.ts +81 -0
- package/src/lang/browser.ts +13 -0
- package/src/lang/runtime.test.ts +46 -0
- package/src/lang/ts-cdn-shim.ts +35 -0
- package/src/runtime.test.ts +6 -6
- package/src/vm/equality.test.ts +59 -0
- package/src/vm/runtime.ts +26 -59
package/llms.txt
CHANGED
|
@@ -44,6 +44,8 @@ This file is a navigation index for AI agents. It does not contain the docs them
|
|
|
44
44
|
- `tjs-lang` → `src/index.ts` — main entry: `Agent`, `AgentVM`, `ajs`, `tjs`.
|
|
45
45
|
- `tjs-lang/lang` → `src/lang/transpiler.ts` — language tools only: `tjs`, `transpile`.
|
|
46
46
|
- `tjs-lang/lang/from-ts` → `src/lang/emitters/from-ts.ts` — TypeScript → TJS/JS transpilation.
|
|
47
|
+
- `tjs-lang/browser` → `src/lang/browser.ts` (dist `tjs-browser.js`) — **self-contained** TJS/AJS transpiler (acorn + tosijs-schema inlined); `import()` from any CDN, zero config.
|
|
48
|
+
- `tjs-lang/browser/from-ts` → `src/lang/browser-from-ts.ts` (dist `tjs-browser-from-ts.js`) — browser TS→TJS; lazy-loads the `typescript` compiler from a CDN (default `https://esm.sh/typescript@5` — the only CDN that serves it reliably; jsDelivr `+esm`/esm.run time out). Override with `fromTS(src, { typescriptUrl })` or preload `globalThis.__TJS_TS__`.
|
|
47
49
|
- `tjs-lang/vm` → `src/vm/index.ts` — VM and atoms.
|
|
48
50
|
- `tjs-lang/eval` → `src/lang/eval.ts` — `Eval`, `SafeFunction`.
|
|
49
51
|
- `tjs-lang/batteries` → `src/batteries/index.ts` — LM Studio integration (lazy, local-only).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tjs-lang",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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",
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
"lang/from-ts": [
|
|
34
34
|
"./dist/src/lang/emitters/from-ts.d.ts"
|
|
35
35
|
],
|
|
36
|
+
"browser": [
|
|
37
|
+
"./dist/src/lang/browser.d.ts"
|
|
38
|
+
],
|
|
39
|
+
"browser/from-ts": [
|
|
40
|
+
"./dist/src/lang/browser-from-ts.d.ts"
|
|
41
|
+
],
|
|
36
42
|
"vm": [
|
|
37
43
|
"./dist/src/vm/index.d.ts"
|
|
38
44
|
],
|
|
@@ -62,6 +68,14 @@
|
|
|
62
68
|
"types": "./dist/src/lang/emitters/from-ts.d.ts",
|
|
63
69
|
"default": "./dist/tjs-from-ts.js"
|
|
64
70
|
},
|
|
71
|
+
"./browser": {
|
|
72
|
+
"types": "./dist/src/lang/browser.d.ts",
|
|
73
|
+
"default": "./dist/tjs-browser.js"
|
|
74
|
+
},
|
|
75
|
+
"./browser/from-ts": {
|
|
76
|
+
"types": "./dist/src/lang/browser-from-ts.d.ts",
|
|
77
|
+
"default": "./dist/tjs-browser-from-ts.js"
|
|
78
|
+
},
|
|
65
79
|
"./vm": {
|
|
66
80
|
"bun": "./src/vm/index.ts",
|
|
67
81
|
"types": "./dist/src/vm/index.d.ts",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the browser bundles' defining property: they must be **self-contained**
|
|
3
|
+
* (no bare/node imports) so a single `import('https://cdn/.../tjs-browser.js')`
|
|
4
|
+
* works on any CDN with zero import-map/config. Builds in-test with esbuild, so
|
|
5
|
+
* it doesn't depend on `dist/` existing.
|
|
6
|
+
*
|
|
7
|
+
* CDN reality (verified in a real headless browser, 2026-06): the self-contained
|
|
8
|
+
* TJS/AJS bundle loads from ANY CDN; for the TypeScript path, the compiler is
|
|
9
|
+
* lazy-loaded and **esm.sh is the only CDN that reliably serves `typescript`**
|
|
10
|
+
* (jsDelivr `+esm` / esm.run time out on its ~10MB CJS; skypack is dead).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'bun:test'
|
|
13
|
+
import { buildSync } from 'esbuild'
|
|
14
|
+
import { DEFAULT_TYPESCRIPT_URL } from './browser-from-ts'
|
|
15
|
+
|
|
16
|
+
const HERE = import.meta.dir
|
|
17
|
+
|
|
18
|
+
function bundle(entry: string, alias?: Record<string, string>): string {
|
|
19
|
+
const r = buildSync({
|
|
20
|
+
entryPoints: [`${HERE}/${entry}`],
|
|
21
|
+
bundle: true,
|
|
22
|
+
format: 'esm',
|
|
23
|
+
platform: 'neutral',
|
|
24
|
+
write: false,
|
|
25
|
+
external: [],
|
|
26
|
+
alias,
|
|
27
|
+
})
|
|
28
|
+
return r.outputFiles[0].text
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Bare (non-relative, non-URL) static + dynamic import specifiers. */
|
|
32
|
+
function bareImports(src: string): string[] {
|
|
33
|
+
const found: string[] = []
|
|
34
|
+
for (const m of src.matchAll(
|
|
35
|
+
/(?:^|[;\n}])import\s*(?:[^"';]*?from)?\s*["']([^"']+)["']/g
|
|
36
|
+
))
|
|
37
|
+
found.push(m[1])
|
|
38
|
+
for (const m of src.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g))
|
|
39
|
+
found.push(m[1])
|
|
40
|
+
return [...new Set(found)].filter(
|
|
41
|
+
(s) => !s.startsWith('.') && !s.startsWith('http')
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('browser bundles are self-contained (CDN drop-in)', () => {
|
|
46
|
+
it('tjs-browser inlines all deps — no bare/node imports', () => {
|
|
47
|
+
const out = bundle('browser.ts')
|
|
48
|
+
// bareImports([]) already implies no `node:` imports (they'd be bare too);
|
|
49
|
+
// a raw `out.includes('node:')` would false-positive on `{node: ...}` props.
|
|
50
|
+
expect(bareImports(out)).toEqual([])
|
|
51
|
+
// sanity: acorn really got inlined (parser internals present)
|
|
52
|
+
expect(out.length).toBeGreaterThan(100_000)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('tjs-browser-from-ts: no bare imports, TS lazy (not inlined)', () => {
|
|
56
|
+
const out = bundle('browser-from-ts.ts', {
|
|
57
|
+
typescript: `${HERE}/ts-cdn-shim.ts`,
|
|
58
|
+
})
|
|
59
|
+
expect(bareImports(out)).toEqual([])
|
|
60
|
+
// typescript must NOT be bundled in (it's ~10MB) — the shim swaps it out
|
|
61
|
+
expect(out.length).toBeLessThan(300_000)
|
|
62
|
+
// the only runtime dependency is the configurable compiler URL
|
|
63
|
+
expect(out).toContain('esm.sh/typescript')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('default TypeScript CDN is esm.sh (the only one that serves it reliably)', () => {
|
|
67
|
+
expect(DEFAULT_TYPESCRIPT_URL).toBe('https://esm.sh/typescript@5')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser TypeScript→TJS — lazy-loads the TypeScript compiler from a CDN on
|
|
3
|
+
* demand, so the bundle stays small and only pays the (~MB) compiler download
|
|
4
|
+
* when you actually transpile TS. `acorn` + `tosijs-schema` are bundled in; the
|
|
5
|
+
* `typescript` import in `from-ts.ts` is aliased (at build time) to a Proxy that
|
|
6
|
+
* forwards to the lazily-loaded compiler (see `ts-cdn-shim.ts`).
|
|
7
|
+
*
|
|
8
|
+
* const { fromTS } = await import('https://cdn.jsdelivr.net/npm/tjs-lang/dist/tjs-browser-from-ts.js')
|
|
9
|
+
* const { code } = await fromTS('const x: number = 1', { emitTJS: true })
|
|
10
|
+
*
|
|
11
|
+
* Zero config by default. Override the compiler source with `typescriptUrl` (or
|
|
12
|
+
* preload it yourself onto `globalThis.__TJS_TS__`).
|
|
13
|
+
*/
|
|
14
|
+
import type { FromTSOptions, FromTSResult } from './emitters/from-ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default CDN for the TypeScript compiler. **esm.sh** — verified the only CDN
|
|
18
|
+
* that reliably serves `typescript` as ESM (default export = the compiler
|
|
19
|
+
* namespace), in ~700ms. The on-the-fly bundlers choke on typescript's ~10MB
|
|
20
|
+
* CommonJS size: jsDelivr `+esm`, esm.run → timeout; skypack → dead. So esm.sh
|
|
21
|
+
* it is. Override via `BrowserFromTSOptions.typescriptUrl` (e.g. a self-hosted
|
|
22
|
+
* copy) or preload your own compiler onto `globalThis.__TJS_TS__`.
|
|
23
|
+
*
|
|
24
|
+
* NOTE: only the *TypeScript* path depends on this. The TJS/AJS transpiler
|
|
25
|
+
* (`tjs-lang/browser`) is fully self-contained and loads from ANY CDN.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_TYPESCRIPT_URL = 'https://esm.sh/typescript@5'
|
|
28
|
+
|
|
29
|
+
const TS_GLOBAL = '__TJS_TS__'
|
|
30
|
+
|
|
31
|
+
export interface BrowserFromTSOptions extends FromTSOptions {
|
|
32
|
+
/** Override the CDN URL the TypeScript compiler is lazy-loaded from. */
|
|
33
|
+
typescriptUrl?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let loading: Promise<void> | null = null
|
|
37
|
+
|
|
38
|
+
/** Lazy-load the TypeScript compiler (once) onto `globalThis.__TJS_TS__`. */
|
|
39
|
+
export function loadTypeScript(
|
|
40
|
+
url: string = DEFAULT_TYPESCRIPT_URL
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
if ((globalThis as any)[TS_GLOBAL]) return Promise.resolve()
|
|
43
|
+
if (!loading) {
|
|
44
|
+
loading = import(/* @vite-ignore */ /* webpackIgnore: true */ url).then(
|
|
45
|
+
(mod) => {
|
|
46
|
+
const ts = mod?.default ?? mod
|
|
47
|
+
if (!ts || typeof ts.createSourceFile !== 'function') {
|
|
48
|
+
loading = null
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Loaded ${url} but it is not a usable TypeScript compiler ` +
|
|
51
|
+
`(no createSourceFile). Try a different typescriptUrl.`
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
;(globalThis as any)[TS_GLOBAL] = ts
|
|
55
|
+
},
|
|
56
|
+
(e) => {
|
|
57
|
+
loading = null
|
|
58
|
+
throw e
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
return loading
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Transpile TypeScript → TJS (or JS) in the browser. Lazy-loads the TypeScript
|
|
67
|
+
* compiler on first call, then runs the same `fromTS` logic as Node.
|
|
68
|
+
*/
|
|
69
|
+
export async function fromTS(
|
|
70
|
+
source: string,
|
|
71
|
+
options: BrowserFromTSOptions = {}
|
|
72
|
+
): Promise<FromTSResult> {
|
|
73
|
+
const { typescriptUrl, ...rest } = options
|
|
74
|
+
await loadTypeScript(typescriptUrl)
|
|
75
|
+
// Imported after the compiler is set; from-ts's `typescript` import is aliased
|
|
76
|
+
// to the Proxy shim, which now forwards to the loaded compiler.
|
|
77
|
+
const { fromTS: nodeFromTS } = await import('./emitters/from-ts')
|
|
78
|
+
return nodeFromTS(source, rest)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type { FromTSOptions, FromTSResult } from './emitters/from-ts'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained browser entry — the TJS/AJS transpiler with `acorn` and
|
|
3
|
+
* `tosijs-schema` bundled in (no external/bare imports). Build target
|
|
4
|
+
* `tjs-browser` (see `scripts/build.ts`) emits a single ESM file you can
|
|
5
|
+
* `import()` from any CDN with zero import-map / config:
|
|
6
|
+
*
|
|
7
|
+
* const { tjs } = await import('https://cdn.jsdelivr.net/npm/tjs-lang/dist/tjs-browser.js')
|
|
8
|
+
* const { code } = tjs("function greet(name: 'x'): '' { return 'hi' }")
|
|
9
|
+
*
|
|
10
|
+
* For TypeScript→TJS in the browser, use `tjs-lang/browser/from-ts` (which
|
|
11
|
+
* lazy-loads the TypeScript compiler from a CDN on demand).
|
|
12
|
+
*/
|
|
13
|
+
export * from './transpiler'
|
package/src/lang/runtime.test.ts
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
isNativeType,
|
|
28
28
|
Is,
|
|
29
29
|
IsNot,
|
|
30
|
+
Eq,
|
|
31
|
+
NotEq,
|
|
30
32
|
tjsEquals,
|
|
31
33
|
checkType,
|
|
32
34
|
MonadicError,
|
|
@@ -1178,3 +1180,47 @@ describe('predicate reason strings', () => {
|
|
|
1178
1180
|
expect(err!.reason).toBeUndefined()
|
|
1179
1181
|
})
|
|
1180
1182
|
})
|
|
1183
|
+
|
|
1184
|
+
// `==`/`!=` (which transpile to Eq/NotEq under TjsEquals) are **footgun-free
|
|
1185
|
+
// `===`**, NOT structural. They unwrap boxed primitives and treat null/undefined
|
|
1186
|
+
// as equal — but distinct objects/arrays are genuinely distinct (a real
|
|
1187
|
+
// distinction, and structural `==` would be a silent O(n) perf hit). Deep
|
|
1188
|
+
// structural comparison is the separate `Is`/`IsNot` function. This block pins
|
|
1189
|
+
// that design so docs/behavior can't drift back to "== is structural".
|
|
1190
|
+
describe('equality semantics — == (Eq) is footgun-free, NOT structural', () => {
|
|
1191
|
+
it('fixes the boxed-primitive footgun (unlike ===)', () => {
|
|
1192
|
+
expect(Eq(new Boolean(false), false)).toBe(true)
|
|
1193
|
+
expect(Eq(new Number(5), 5)).toBe(true)
|
|
1194
|
+
expect(Eq(new String('x'), 'x')).toBe(true)
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
it('does NOT coerce across types (unlike JS ==)', () => {
|
|
1198
|
+
expect(Eq('5', 5)).toBe(false)
|
|
1199
|
+
expect(Eq('', false)).toBe(false)
|
|
1200
|
+
expect(Eq(0, false)).toBe(false)
|
|
1201
|
+
expect(Eq(1, true)).toBe(false)
|
|
1202
|
+
expect(Eq(null, 0)).toBe(false)
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
it('treats null and undefined as equal; NaN as equal to itself', () => {
|
|
1206
|
+
expect(Eq(null, undefined)).toBe(true)
|
|
1207
|
+
expect(Eq(NaN, NaN)).toBe(true)
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
it('is NOT structural — distinct objects/arrays are distinct', () => {
|
|
1211
|
+
expect(Eq({ a: 1 }, { a: 1 })).toBe(false)
|
|
1212
|
+
expect(Eq([1, 2], [1, 2])).toBe(false)
|
|
1213
|
+
const shared = { a: 1 }
|
|
1214
|
+
expect(Eq(shared, shared)).toBe(true) // identity still holds
|
|
1215
|
+
expect(NotEq([1, 2], [1, 2])).toBe(true)
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
it('Is/IsNot ARE structural — that is the deep-comparison path', () => {
|
|
1219
|
+
expect(Is({ a: 1 }, { a: 1 })).toBe(true)
|
|
1220
|
+
expect(Is([1, 2], [1, 2])).toBe(true)
|
|
1221
|
+
expect(Is({ a: { b: [1] } }, { a: { b: [1] } })).toBe(true)
|
|
1222
|
+
expect(IsNot([1, 2], [1, 3])).toBe(true)
|
|
1223
|
+
// and Is is not fooled into structural-comparing unequal shapes
|
|
1224
|
+
expect(Is({ a: 1 }, { a: 2 })).toBe(false)
|
|
1225
|
+
})
|
|
1226
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time stand-in for the `typescript` module, used ONLY by the
|
|
3
|
+
* `tjs-browser-from-ts` bundle (via an esbuild `alias`). `from-ts.ts` does
|
|
4
|
+
* `import ts from 'typescript'` and accesses `ts.<x>` at call time; this Proxy
|
|
5
|
+
* forwards every access to the compiler that `browser-from-ts.ts` lazy-loads
|
|
6
|
+
* from a CDN and stashes on `globalThis.__TJS_TS__`.
|
|
7
|
+
*
|
|
8
|
+
* Because all of from-ts's `ts.<x>` uses are inside functions (run when `fromTS`
|
|
9
|
+
* is called, never at module load), the access is always deferred to after the
|
|
10
|
+
* compiler is loaded — so the swap needs no changes to `from-ts.ts`. tsc still
|
|
11
|
+
* typechecks `from-ts.ts` against the real `typescript` types; only the browser
|
|
12
|
+
* BUILD aliases the runtime to this shim.
|
|
13
|
+
*/
|
|
14
|
+
const TS_GLOBAL = '__TJS_TS__'
|
|
15
|
+
|
|
16
|
+
function compiler(): any {
|
|
17
|
+
const ts = (globalThis as any)[TS_GLOBAL]
|
|
18
|
+
if (!ts) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'TypeScript not loaded. Use fromTS() from tjs-lang/browser/from-ts, ' +
|
|
21
|
+
'which lazy-loads the compiler before transpiling.'
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
return ts
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const shim: any = new Proxy(
|
|
28
|
+
{},
|
|
29
|
+
{
|
|
30
|
+
get: (_t, prop) => compiler()[prop],
|
|
31
|
+
has: (_t, prop) => prop in compiler(),
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export default shim
|
package/src/runtime.test.ts
CHANGED
|
@@ -596,8 +596,8 @@ describe('Edge Cases', () => {
|
|
|
596
596
|
expect(result.result.res).toEqual({ value: 42 })
|
|
597
597
|
})
|
|
598
598
|
|
|
599
|
-
describe('
|
|
600
|
-
it('==
|
|
599
|
+
describe('footgun-free equality in expressions (== is NOT structural)', () => {
|
|
600
|
+
it('distinct arrays are NOT equal (== matches TJS, not structural)', async () => {
|
|
601
601
|
const ast = {
|
|
602
602
|
op: 'seq',
|
|
603
603
|
steps: [
|
|
@@ -621,10 +621,10 @@ describe('Edge Cases', () => {
|
|
|
621
621
|
],
|
|
622
622
|
} as any
|
|
623
623
|
const result = await vm.run(ast, {})
|
|
624
|
-
expect(result.result.res).toBe(
|
|
624
|
+
expect(result.result.res).toBe(false) // distinct arrays — NOT structural
|
|
625
625
|
})
|
|
626
626
|
|
|
627
|
-
it('==
|
|
627
|
+
it('distinct objects are NOT equal (== matches TJS, not structural)', async () => {
|
|
628
628
|
const ast = {
|
|
629
629
|
op: 'seq',
|
|
630
630
|
steps: [
|
|
@@ -648,7 +648,7 @@ describe('Edge Cases', () => {
|
|
|
648
648
|
],
|
|
649
649
|
} as any
|
|
650
650
|
const result = await vm.run(ast, {})
|
|
651
|
-
expect(result.result.res).toBe(
|
|
651
|
+
expect(result.result.res).toBe(false) // distinct objects — NOT structural
|
|
652
652
|
})
|
|
653
653
|
|
|
654
654
|
it('== does not coerce types', async () => {
|
|
@@ -678,7 +678,7 @@ describe('Edge Cases', () => {
|
|
|
678
678
|
expect(result.result.res).toBe(false) // no coercion
|
|
679
679
|
})
|
|
680
680
|
|
|
681
|
-
it('!= returns true for
|
|
681
|
+
it('!= returns true for distinct objects', async () => {
|
|
682
682
|
const ast = {
|
|
683
683
|
op: 'seq',
|
|
684
684
|
steps: [
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { evaluateExpr } from './runtime'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AJS `==`/`!=` must match TJS `==` (Eq): footgun-free `===`, NOT structural.
|
|
6
|
+
* This used to do deep structural comparison in the VM — an early, unconsidered
|
|
7
|
+
* divergence from TJS `==` (so `[1,2] == [1,2]` was `true` in AJS but `false` in
|
|
8
|
+
* TJS). Now consistent: distinct objects/arrays are distinct; structural
|
|
9
|
+
* equality is an explicit operation, never `==`.
|
|
10
|
+
*/
|
|
11
|
+
const lit = (value: unknown) => ({ $expr: 'literal' as const, value })
|
|
12
|
+
const evalEq = (op: '==' | '!=', a: unknown, b: unknown) =>
|
|
13
|
+
evaluateExpr(
|
|
14
|
+
{ $expr: 'binary', op, left: lit(a), right: lit(b) } as any,
|
|
15
|
+
{ state: {}, args: {} } as any
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
describe('AJS == / != — footgun-free, NOT structural (consistent with TJS)', () => {
|
|
19
|
+
it('distinct arrays/objects are NOT equal (the divergence fix)', () => {
|
|
20
|
+
expect(evalEq('==', [1, 2], [1, 2])).toBe(false)
|
|
21
|
+
expect(evalEq('==', { a: 1 }, { a: 1 })).toBe(false)
|
|
22
|
+
expect(evalEq('!=', [1, 2], [1, 2])).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('identity still holds for the same reference', () => {
|
|
26
|
+
const shared = { a: 1 }
|
|
27
|
+
expect(evalEq('==', shared, shared)).toBe(true)
|
|
28
|
+
const arr = [1, 2]
|
|
29
|
+
expect(evalEq('==', arr, arr)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('no type coercion', () => {
|
|
33
|
+
expect(evalEq('==', '5', 5)).toBe(false)
|
|
34
|
+
expect(evalEq('==', '', false)).toBe(false)
|
|
35
|
+
expect(evalEq('==', 0, false)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('unwraps boxed primitives', () => {
|
|
39
|
+
expect(evalEq('==', new Boolean(false) as any, false)).toBe(true)
|
|
40
|
+
expect(evalEq('==', new Number(5) as any, 5)).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('null/undefined equal; NaN equal to itself', () => {
|
|
44
|
+
expect(evalEq('==', null, undefined)).toBe(true)
|
|
45
|
+
expect(evalEq('==', NaN, NaN)).toBe(true)
|
|
46
|
+
expect(evalEq('==', null, 0)).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('scalars compare as expected', () => {
|
|
50
|
+
expect(evalEq('==', 1, 1)).toBe(true)
|
|
51
|
+
expect(evalEq('==', 'x', 'x')).toBe(true)
|
|
52
|
+
expect(evalEq('!=', 1, 2)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('=== / !== remain strict identity (unchanged)', () => {
|
|
56
|
+
expect(evalEq('===' as any, [1], [1])).toBe(false)
|
|
57
|
+
expect(evalEq('===' as any, 5, 5)).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
})
|
package/src/vm/runtime.ts
CHANGED
|
@@ -1,69 +1,36 @@
|
|
|
1
1
|
import { s, validate, filter as schemaFilter } from 'tosijs-schema'
|
|
2
2
|
|
|
3
|
-
/** Well-known symbol for custom equality (matches src/lang/runtime.ts) */
|
|
4
|
-
const tjsEquals = Symbol.for('tjs.equals')
|
|
5
|
-
|
|
6
3
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* AJS `==`/`!=` equality — **footgun-free `===`**, consistent with TJS `Eq`
|
|
5
|
+
* (`src/lang/runtime.ts`). NOT structural: it unwraps boxed primitives, treats
|
|
6
|
+
* `null`/`undefined` as equal and `NaN` as equal to itself, but does NOT coerce
|
|
7
|
+
* across types, and distinct objects/arrays are distinct (`[1,2] != [1,2]`).
|
|
8
|
+
*
|
|
9
|
+
* (Originally the VM did deep structural comparison here — an early, unconsidered
|
|
10
|
+
* divergence from TJS `==`. It was also a SECURITY hole: a single `==` node costs
|
|
11
|
+
* a flat EXPR_FUEL_COST, but the structural walk charged nothing per element, so
|
|
12
|
+
* `==` on an attacker-controlled large structure was an unbounded-work / fuel-
|
|
13
|
+
* bypass DoS — exactly what AJS exists to prevent. `eqValue` is O(1).
|
|
14
|
+
*
|
|
15
|
+
* Structural equality is an explicit operation: in TJS it's the `Is`/`IsNot`
|
|
16
|
+
* function; AJS can grow an `Is` atom if/when needed — but it MUST be fuel-metered
|
|
17
|
+
* per element compared, or it reintroduces the same DoS.)
|
|
9
18
|
*/
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
a !== null &&
|
|
14
|
-
typeof a === 'object' &&
|
|
15
|
-
typeof (a as any)[tjsEquals] === 'function'
|
|
16
|
-
) {
|
|
17
|
-
return (a as any)[tjsEquals](b)
|
|
18
|
-
}
|
|
19
|
-
if (
|
|
20
|
-
b !== null &&
|
|
21
|
-
typeof b === 'object' &&
|
|
22
|
-
typeof (b as any)[tjsEquals] === 'function'
|
|
23
|
-
) {
|
|
24
|
-
return (b as any)[tjsEquals](a)
|
|
19
|
+
function eqValue(a: unknown, b: unknown): boolean {
|
|
20
|
+
if (a instanceof String || a instanceof Number || a instanceof Boolean) {
|
|
21
|
+
a = a.valueOf()
|
|
25
22
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
a !== null &&
|
|
30
|
-
typeof a === 'object' &&
|
|
31
|
-
typeof (a as any).Equals === 'function'
|
|
32
|
-
) {
|
|
33
|
-
return (a as any).Equals(b)
|
|
34
|
-
}
|
|
35
|
-
if (
|
|
36
|
-
b !== null &&
|
|
37
|
-
typeof b === 'object' &&
|
|
38
|
-
typeof (b as any).Equals === 'function'
|
|
39
|
-
) {
|
|
40
|
-
return (b as any).Equals(a)
|
|
23
|
+
if (b instanceof String || b instanceof Number || b instanceof Boolean) {
|
|
24
|
+
b = b.valueOf()
|
|
41
25
|
}
|
|
42
|
-
|
|
43
26
|
if (a === b) return true
|
|
44
|
-
|
|
45
|
-
// Nullish equality (null == undefined)
|
|
46
|
-
if ((a === null || a === undefined) && (b === null || b === undefined))
|
|
27
|
+
if (typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b)) {
|
|
47
28
|
return true
|
|
48
|
-
|
|
49
|
-
if (a === null || a === undefined || b === null || b === undefined)
|
|
50
|
-
return false
|
|
51
|
-
|
|
52
|
-
if (typeof a !== typeof b) return false
|
|
53
|
-
if (typeof a !== 'object') return false
|
|
54
|
-
|
|
55
|
-
// Arrays
|
|
56
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
57
|
-
if (a.length !== b.length) return false
|
|
58
|
-
return a.every((v, i) => isStructurallyEqual(v, b[i]))
|
|
59
29
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const keysB = Object.keys(b as object)
|
|
65
|
-
if (keysA.length !== keysB.length) return false
|
|
66
|
-
return keysA.every((k) => isStructurallyEqual((a as any)[k], (b as any)[k]))
|
|
30
|
+
if ((a === null || a === undefined) && (b === null || b === undefined)) {
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
67
34
|
}
|
|
68
35
|
|
|
69
36
|
// --- Monadic Error Type ---
|
|
@@ -1204,9 +1171,9 @@ export function evaluateExpr(node: ExprNode, ctx: RuntimeContext): any {
|
|
|
1204
1171
|
case '<=':
|
|
1205
1172
|
return left <= right
|
|
1206
1173
|
case '==':
|
|
1207
|
-
return
|
|
1174
|
+
return eqValue(left, right)
|
|
1208
1175
|
case '!=':
|
|
1209
|
-
return !
|
|
1176
|
+
return !eqValue(left, right)
|
|
1210
1177
|
case '===':
|
|
1211
1178
|
return left === right
|
|
1212
1179
|
case '!==':
|