tjs-lang 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CLAUDE.md +5 -2
  2. package/demo/autocomplete.test.ts +37 -0
  3. package/demo/docs.json +701 -11
  4. package/demo/src/autocomplete.ts +40 -2
  5. package/demo/src/introspection-bridge.ts +140 -0
  6. package/demo/src/introspection-doc.test.ts +63 -0
  7. package/demo/src/playground-shared.ts +53 -0
  8. package/demo/src/tjs-playground.ts +21 -0
  9. package/dist/experiments/predicates/css.predicates.d.ts +13 -0
  10. package/dist/experiments/predicates/verify.d.ts +39 -0
  11. package/dist/index.js +101 -97
  12. package/dist/index.js.map +4 -4
  13. package/dist/src/lang/index.d.ts +2 -0
  14. package/dist/src/lang/predicate-schema.d.ts +39 -0
  15. package/dist/src/lang/predicate.d.ts +103 -0
  16. package/dist/src/lang/transpiler.d.ts +2 -0
  17. package/dist/src/vm/runtime.d.ts +22 -0
  18. package/dist/tjs-eval.js +29 -29
  19. package/dist/tjs-eval.js.map +3 -3
  20. package/dist/tjs-from-ts.js +1 -1
  21. package/dist/tjs-from-ts.js.map +2 -2
  22. package/dist/tjs-lang.js +86 -82
  23. package/dist/tjs-lang.js.map +4 -4
  24. package/dist/tjs-vm.js +45 -45
  25. package/dist/tjs-vm.js.map +3 -3
  26. package/editors/codemirror/ajs-language.ts +130 -44
  27. package/editors/codemirror/completion-source.test.ts +114 -0
  28. package/editors/introspect-value.test.ts +61 -0
  29. package/editors/introspect-value.ts +86 -0
  30. package/editors/scope-symbols.test.ts +113 -0
  31. package/editors/scope-symbols.ts +173 -0
  32. package/package.json +2 -1
  33. package/src/lang/index.ts +22 -0
  34. package/src/lang/predicate-schema.test.ts +97 -0
  35. package/src/lang/predicate-schema.ts +168 -0
  36. package/src/lang/predicate.test.ts +184 -0
  37. package/src/lang/predicate.ts +550 -0
  38. package/src/lang/runtime.test.ts +46 -0
  39. package/src/lang/suggest.test.ts +84 -0
  40. package/src/lang/transpiler.ts +26 -0
  41. package/src/runtime.test.ts +6 -6
  42. package/src/vm/atom-effects.test.ts +72 -0
  43. package/src/vm/atoms/batteries.ts +12 -0
  44. package/src/vm/equality.test.ts +59 -0
  45. package/src/vm/runtime.ts +77 -59
@@ -24,6 +24,32 @@ export {
24
24
  type SourceKind,
25
25
  } from './dialect'
26
26
 
27
+ // Predicate-safety: verify a cluster of pure, synchronous, composable predicates
28
+ export {
29
+ verifyPredicate,
30
+ compilePredicate,
31
+ suggest,
32
+ effectfulFromAtoms,
33
+ formatPredicateDiagnostics,
34
+ PredicateFuelExhausted,
35
+ type PredicateDiagnostic,
36
+ type PredicateVerifyResult,
37
+ type VerifyPredicateOptions,
38
+ type CompilePredicateOptions,
39
+ type Suggestion,
40
+ type SuggestOptions,
41
+ } from './predicate'
42
+
43
+ // Predicate-aware JSON-Schema: the `$predicate` keyword (computational types)
44
+ export {
45
+ compilePredicateSchema,
46
+ validatePredicateSchema,
47
+ type PredicateSchema,
48
+ type SchemaError,
49
+ type SchemaValidationResult,
50
+ type PredicateSchemaOptions,
51
+ } from './predicate-schema'
52
+
27
53
  // AST emitter
28
54
  export { transformFunction } from './emitters/ast'
29
55
 
@@ -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,72 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { coreAtoms, EFFECTFUL_CORE_OPS, type AtomDef } from './runtime'
3
+ import { batteryAtoms } from './atoms/index'
4
+
5
+ const allAtoms = { ...coreAtoms, ...batteryAtoms } as Record<string, AtomDef>
6
+
7
+ describe('atom effects classification (predicate-safety keystone)', () => {
8
+ it('every atom is classified pure | io', () => {
9
+ for (const [op, atom] of Object.entries(allAtoms)) {
10
+ expect(atom.effects, `${op} has no effects tag`).toMatch(/^(pure|io)$/)
11
+ }
12
+ })
13
+
14
+ it('the declared effectful core ops are all tagged io', () => {
15
+ for (const op of EFFECTFUL_CORE_OPS) {
16
+ expect(coreAtoms[op as keyof typeof coreAtoms]?.effects, op).toBe('io')
17
+ }
18
+ })
19
+
20
+ it('every battery atom is io (network calls)', () => {
21
+ for (const atom of Object.values(batteryAtoms) as AtomDef[]) {
22
+ expect(atom.effects, atom.op).toBe('io')
23
+ }
24
+ })
25
+
26
+ it('the pure substrate a predicate relies on is tagged pure', () => {
27
+ // data / control-flow atoms predicates compose from
28
+ const pure = [
29
+ 'map',
30
+ 'filter',
31
+ 'reduce',
32
+ 'find',
33
+ 'len',
34
+ 'split',
35
+ 'join',
36
+ 'keys',
37
+ 'pick',
38
+ 'omit',
39
+ 'merge',
40
+ 'regexMatch',
41
+ 'jsonParse',
42
+ 'callLocal',
43
+ 'if',
44
+ 'while',
45
+ 'return',
46
+ 'scope',
47
+ ]
48
+ for (const op of pure) {
49
+ expect(coreAtoms[op as keyof typeof coreAtoms]?.effects, op).toBe('pure')
50
+ }
51
+ })
52
+
53
+ it('the IO atoms are exactly the ones that may not appear in a predicate', () => {
54
+ const io = Object.values(allAtoms)
55
+ .filter((a) => a.effects === 'io')
56
+ .map((a) => a.op)
57
+ // sanity: the capability-touching atoms are all present
58
+ expect(io).toEqual(
59
+ expect.arrayContaining([
60
+ 'httpFetch',
61
+ 'storeGet',
62
+ 'llmPredict',
63
+ 'agentRun',
64
+ 'runCode',
65
+ 'llmVision',
66
+ ])
67
+ )
68
+ // and none of the pure data ops leaked in
69
+ expect(io).not.toContain('map')
70
+ expect(io).not.toContain('jsonParse')
71
+ })
72
+ })
@@ -229,3 +229,15 @@ export const llmVision = defineAtom(
229
229
  },
230
230
  { docs: 'Analyze images using a vision model', timeoutMs: 120000, cost: 150 }
231
231
  )
232
+
233
+ // Every battery atom is IO (embedding/LLM/vector-store network calls).
234
+ for (const atom of [
235
+ storeVectorize,
236
+ storeCreateCollection,
237
+ storeVectorAdd,
238
+ storeSearch,
239
+ llmPredictBattery,
240
+ llmVision,
241
+ ]) {
242
+ atom.effects = 'io'
243
+ }
@@ -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)
19
+ function eqValue(a: unknown, b: unknown): boolean {
20
+ if (a instanceof String || a instanceof Number || a instanceof Boolean) {
21
+ a = a.valueOf()
18
22
  }
19
- if (
20
- b !== null &&
21
- typeof b === 'object' &&
22
- typeof (b as any)[tjsEquals] === 'function'
23
- ) {
24
- return (b as any)[tjsEquals](a)
23
+ if (b instanceof String || b instanceof Number || b instanceof Boolean) {
24
+ b = b.valueOf()
25
25
  }
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)
41
- }
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 ---
@@ -180,6 +147,18 @@ export interface RuntimeContext {
180
147
 
181
148
  export type AtomExec = (step: any, ctx: RuntimeContext) => Promise<void>
182
149
 
150
+ /**
151
+ * Effect classification of an atom.
152
+ * - `'pure'`: deterministic, no IO, no capability access, no observable side
153
+ * effects — safe inside a synchronous predicate (see the predicate verifier).
154
+ * - `'io'`: touches `ctx.capabilities` (fetch/store/llm/agent/code), or is
155
+ * nondeterministic (random/uuid), or has side effects (console). Not allowed
156
+ * in a predicate.
157
+ * Defaults to `'pure'`; effectful atoms must opt into `'io'`. The invariant
158
+ * "anything touching ctx.capabilities is tagged 'io'" is guarded by a test.
159
+ */
160
+ export type AtomEffects = 'pure' | 'io'
161
+
183
162
  export interface AtomDef {
184
163
  op: OpCode
185
164
  inputSchema: any
@@ -188,6 +167,7 @@ export interface AtomDef {
188
167
  docs?: string
189
168
  timeoutMs?: number
190
169
  cost?: number | ((input: any, ctx: RuntimeContext) => number)
170
+ effects?: AtomEffects
191
171
  }
192
172
 
193
173
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -199,6 +179,8 @@ export interface AtomOptions {
199
179
  docs?: string
200
180
  timeoutMs?: number
201
181
  cost?: number | ((input: any, ctx: RuntimeContext) => number)
182
+ /** Effect class — defaults to `'pure'`; effectful atoms set `'io'`. */
183
+ effects?: AtomEffects
202
184
  }
203
185
 
204
186
  export interface RunResult {
@@ -1189,9 +1171,9 @@ export function evaluateExpr(node: ExprNode, ctx: RuntimeContext): any {
1189
1171
  case '<=':
1190
1172
  return left <= right
1191
1173
  case '==':
1192
- return isStructurallyEqual(left, right)
1174
+ return eqValue(left, right)
1193
1175
  case '!=':
1194
- return !isStructurallyEqual(left, right)
1176
+ return !eqValue(left, right)
1195
1177
  case '===':
1196
1178
  return left === right
1197
1179
  case '!==':
@@ -1347,6 +1329,7 @@ export function defineAtom<I extends Record<string, any>, O = any>(
1347
1329
  docs = '',
1348
1330
  timeoutMs = 1000,
1349
1331
  cost = 1,
1332
+ effects = 'pure',
1350
1333
  } = typeof options === 'string' ? { docs: options } : options
1351
1334
 
1352
1335
  const exec: AtomExec = async (step: any, ctx: RuntimeContext) => {
@@ -1446,6 +1429,7 @@ export function defineAtom<I extends Record<string, any>, O = any>(
1446
1429
  docs,
1447
1430
  timeoutMs,
1448
1431
  cost,
1432
+ effects,
1449
1433
  create: (input: I) => ({ op, ...input }),
1450
1434
  }
1451
1435
  }
@@ -3114,3 +3098,37 @@ export const coreAtoms = {
3114
3098
  releaseProcedure,
3115
3099
  clearExpiredProcedures,
3116
3100
  }
3101
+
3102
+ /**
3103
+ * Effectful core atoms — anything that touches `ctx.capabilities`
3104
+ * (fetch/store/llm/agent/code), is nondeterministic (random/uuid), or has
3105
+ * observable side effects (console). Tagged centrally so the list reads as one
3106
+ * audit surface; a test asserts every capability-touching atom is in here.
3107
+ * Everything else defaults to `effects: 'pure'`.
3108
+ */
3109
+ export const EFFECTFUL_CORE_OPS = [
3110
+ 'httpFetch',
3111
+ 'storeGet',
3112
+ 'storeSet',
3113
+ 'storeQuery',
3114
+ 'storeVectorSearch',
3115
+ 'llmPredict',
3116
+ 'agentRun',
3117
+ 'transpileCode',
3118
+ 'runCode',
3119
+ 'random',
3120
+ 'uuid',
3121
+ 'consoleLog',
3122
+ 'consoleWarn',
3123
+ 'consoleError',
3124
+ 'storeProcedure',
3125
+ 'releaseProcedure',
3126
+ 'clearExpiredProcedures',
3127
+ 'cache',
3128
+ 'memoize',
3129
+ ] as const
3130
+
3131
+ for (const op of EFFECTFUL_CORE_OPS) {
3132
+ const atom = (coreAtoms as Record<string, AtomDef>)[op]
3133
+ if (atom) atom.effects = 'io'
3134
+ }