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/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",
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'
@@ -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
@@ -596,8 +596,8 @@ describe('Edge Cases', () => {
596
596
  expect(result.result.res).toEqual({ value: 42 })
597
597
  })
598
598
 
599
- describe('structural equality in expressions', () => {
600
- it('== compares arrays structurally', async () => {
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(true)
624
+ expect(result.result.res).toBe(false) // distinct arrays — NOT structural
625
625
  })
626
626
 
627
- it('== compares objects structurally', async () => {
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(true)
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 structurally different objects', async () => {
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
- * Structural equality for AJS expressions.
8
- * Mirrors the TJS Is() function: symbol .Equals structural.
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 isStructurallyEqual(a: unknown, b: unknown): boolean {
11
- // Symbol protocol
12
- if (
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
- // .Equals method
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 (Array.isArray(a) !== Array.isArray(b)) return false
61
-
62
- // Objects
63
- const keysA = Object.keys(a as object)
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 isStructurallyEqual(left, right)
1174
+ return eqValue(left, right)
1208
1175
  case '!=':
1209
- return !isStructurallyEqual(left, right)
1176
+ return !eqValue(left, right)
1210
1177
  case '===':
1211
1178
  return left === right
1212
1179
  case '!==':