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
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Scope-aware symbol collection for autocomplete.
3
+ *
4
+ * Replaces the regex `const NAME =` scraping (which missed ALL destructuring —
5
+ * and the tosijs examples bind everything via destructuring, so nothing was
6
+ * suggested). This parses the source (acorn, falling back to acorn-loose for
7
+ * mid-edit / not-yet-valid source) and walks binding patterns properly:
8
+ * `const { todoApp } = tosi(...)`, `const { h1, ul } = elements`,
9
+ * `const [a, , b] = xs`, rename, defaults, rest — all yield their bound names.
10
+ *
11
+ * Each symbol also records its **origin** (the initializer expression text, and
12
+ * for object-destructuring the key it came from). That origin is the hook the
13
+ * runtime-introspection layer uses: to introspect `h1` it can evaluate
14
+ * `elements.h1` in the live scope. Names are plumbing; the *types* come from
15
+ * introspecting real values (see the introspection bridge), not from this parse.
16
+ */
17
+ import * as acorn from 'acorn'
18
+ import * as acornLoose from 'acorn-loose'
19
+ import * as walk from 'acorn-walk'
20
+
21
+ export interface SymbolOrigin {
22
+ /** How the binding was produced. */
23
+ via: 'init' | 'destructure' | 'param' | 'import' | 'function'
24
+ /** Source text of the initializer expression (e.g. `elements`, `tosi({...})`). */
25
+ expr?: string
26
+ /** For object-destructuring: the key on `expr` this name came from (`elements.h1` → `h1`). */
27
+ member?: string
28
+ /** Module specifier, for imports. */
29
+ module?: string
30
+ }
31
+
32
+ export interface ScopeSymbol {
33
+ name: string
34
+ kind: 'variable' | 'function' | 'parameter' | 'import'
35
+ origin?: SymbolOrigin
36
+ }
37
+
38
+ function parse(source: string): any {
39
+ try {
40
+ return acorn.parse(source, { ecmaVersion: 'latest' })
41
+ } catch {
42
+ try {
43
+ // Best-effort AST from incomplete / not-yet-valid source.
44
+ return acornLoose.parse(source, { ecmaVersion: 'latest' })
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+ }
50
+
51
+ type NameSink = (name: string, member?: string) => void
52
+
53
+ /** Walk a binding pattern, emitting each bound name (and, one level deep for
54
+ * object patterns, the source key it came from). */
55
+ function collectPattern(pat: any, onName: NameSink, member?: string): void {
56
+ if (!pat) return
57
+ switch (pat.type) {
58
+ case 'Identifier':
59
+ onName(pat.name, member)
60
+ return
61
+ case 'ObjectPattern':
62
+ for (const p of pat.properties) {
63
+ if (p.type === 'RestElement') {
64
+ collectPattern(p.argument, onName)
65
+ } else {
66
+ const key = p.key && (p.key.name ?? p.key.value)
67
+ collectPattern(
68
+ p.value,
69
+ onName,
70
+ typeof key === 'string' ? key : undefined
71
+ )
72
+ }
73
+ }
74
+ return
75
+ case 'ArrayPattern':
76
+ for (const el of pat.elements) collectPattern(el, onName)
77
+ return
78
+ case 'AssignmentPattern':
79
+ collectPattern(pat.left, onName, member)
80
+ return
81
+ case 'RestElement':
82
+ collectPattern(pat.argument, onName)
83
+ return
84
+ }
85
+ }
86
+
87
+ const inRange = (node: any, position: number) =>
88
+ typeof node.start === 'number' &&
89
+ typeof node.end === 'number' &&
90
+ node.start <= position &&
91
+ position <= node.end
92
+
93
+ /**
94
+ * Collect the symbols in scope at `position` (defaults to end of source):
95
+ * variable/function/import bindings declared before the cursor, plus the
96
+ * parameters of any function whose body the cursor sits inside. Destructuring
97
+ * is fully handled; results are de-duplicated by name (last declaration wins).
98
+ */
99
+ export function collectScopeSymbols(
100
+ source: string,
101
+ position: number = source.length
102
+ ): ScopeSymbol[] {
103
+ const ast = parse(source)
104
+ if (!ast) return []
105
+
106
+ const byName = new Map<string, ScopeSymbol>()
107
+ const add = (s: ScopeSymbol) => byName.set(s.name, s)
108
+
109
+ walk.full(ast, (node: any) => {
110
+ switch (node.type) {
111
+ case 'VariableDeclaration': {
112
+ // Only count declarations that appear before the cursor.
113
+ if (node.start >= position) return
114
+ for (const decl of node.declarations) {
115
+ const initText =
116
+ decl.init && typeof decl.init.start === 'number'
117
+ ? source.slice(decl.init.start, decl.init.end)
118
+ : undefined
119
+ collectPattern(decl.id, (name, member) =>
120
+ add({
121
+ name,
122
+ kind: 'variable',
123
+ origin: {
124
+ via: member != null ? 'destructure' : 'init',
125
+ expr: initText,
126
+ member,
127
+ },
128
+ })
129
+ )
130
+ }
131
+ return
132
+ }
133
+ case 'FunctionDeclaration': {
134
+ if (node.id && node.start < position)
135
+ add({
136
+ name: node.id.name,
137
+ kind: 'function',
138
+ origin: { via: 'function' },
139
+ })
140
+ // params only in scope when the cursor is inside the function
141
+ if (inRange(node, position))
142
+ for (const p of node.params)
143
+ collectPattern(p, (name) =>
144
+ add({ name, kind: 'parameter', origin: { via: 'param' } })
145
+ )
146
+ return
147
+ }
148
+ case 'FunctionExpression':
149
+ case 'ArrowFunctionExpression': {
150
+ if (inRange(node, position))
151
+ for (const p of node.params)
152
+ collectPattern(p, (name) =>
153
+ add({ name, kind: 'parameter', origin: { via: 'param' } })
154
+ )
155
+ return
156
+ }
157
+ case 'ImportDeclaration': {
158
+ const module = String(node.source?.value ?? '')
159
+ for (const spec of node.specifiers) {
160
+ // local name is spec.local.name for all specifier kinds
161
+ add({
162
+ name: spec.local.name,
163
+ kind: 'import',
164
+ origin: { via: 'import', module },
165
+ })
166
+ }
167
+ return
168
+ }
169
+ }
170
+ })
171
+
172
+ return [...byName.values()]
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tjs-lang",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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",
@@ -161,6 +161,7 @@
161
161
  },
162
162
  "dependencies": {
163
163
  "acorn": "^8.15.0",
164
+ "acorn-loose": "^8.5.2",
164
165
  "acorn-walk": "^8.3.4",
165
166
  "tosijs-schema": "^1.3.0"
166
167
  }
package/src/lang/index.ts CHANGED
@@ -44,6 +44,28 @@ export {
44
44
  type Dialect,
45
45
  type SourceKind,
46
46
  } from './dialect'
47
+ export {
48
+ verifyPredicate,
49
+ compilePredicate,
50
+ suggest,
51
+ effectfulFromAtoms,
52
+ formatPredicateDiagnostics,
53
+ PredicateFuelExhausted,
54
+ type PredicateDiagnostic,
55
+ type PredicateVerifyResult,
56
+ type VerifyPredicateOptions,
57
+ type CompilePredicateOptions,
58
+ type Suggestion,
59
+ type SuggestOptions,
60
+ } from './predicate'
61
+ export {
62
+ compilePredicateSchema,
63
+ validatePredicateSchema,
64
+ type PredicateSchema,
65
+ type SchemaError,
66
+ type SchemaValidationResult,
67
+ type PredicateSchemaOptions,
68
+ } from './predicate-schema'
47
69
  export { transformFunction } from './emitters/ast'
48
70
  export {
49
71
  transpileToJS,
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import {
3
+ compilePredicateSchema,
4
+ validatePredicateSchema,
5
+ type PredicateSchema,
6
+ } from './predicate-schema'
7
+
8
+ // A composable color predicate cluster (entry = last function, takes the value).
9
+ const COLOR = `
10
+ function isHex(v){ return typeof v == 'string' && /^#[0-9a-f]{3,8}$/i.test(v) }
11
+ function isVar(v){ return typeof v == 'string' && v.startsWith('var(--') && v.endsWith(')') }
12
+ function isCalc(v){ return typeof v == 'string' && v.startsWith('calc(') && v.endsWith(')') }
13
+ function isColor(v){ return isHex(v) || isVar(v) || isCalc(v) }
14
+ `
15
+
16
+ // A recursive predicate cluster — validates a tree of string/number leaves.
17
+ // Note the `!Array.isArray(o)` guard: arrays are objects in JS, so a node check
18
+ // must exclude them explicitly (a real predicate-authoring nuance).
19
+ const TREE = `
20
+ function isLeaf(v){ return typeof v == 'string' || typeof v == 'number' }
21
+ function isEntry(pair){ var val = pair[1]; return isLeaf(val) || isNode(val) }
22
+ function isNode(o){ return typeof o == 'object' && o != null && !Array.isArray(o) && Object.entries(o).every(isEntry) }
23
+ `
24
+
25
+ const colorSchema: PredicateSchema = {
26
+ type: 'object',
27
+ required: ['color'],
28
+ properties: {
29
+ color: { type: 'string', description: 'a CSS color', $predicate: COLOR },
30
+ },
31
+ }
32
+
33
+ describe('predicate-schema — the computational half of JSON-Schema', () => {
34
+ it('a predicate-aware validator validates the value grammar', () => {
35
+ const validate = compilePredicateSchema(colorSchema)
36
+ expect(validate({ color: '#3a3' }).valid).toBe(true)
37
+ expect(validate({ color: 'var(--brand)' }).valid).toBe(true) // TS/JSON-Schema can't
38
+ expect(validate({ color: 'calc(1px + 1em)' }).valid).toBe(true)
39
+
40
+ const bad = validate({ color: 'notacolor' })
41
+ expect(bad.valid).toBe(false)
42
+ expect(bad.errors[0].path).toBe('/color')
43
+ })
44
+
45
+ it('still does structural validation (type / required)', () => {
46
+ const validate = compilePredicateSchema(colorSchema)
47
+ expect(validate({ color: 123 }).errors[0].message).toMatch(
48
+ /expected string/
49
+ )
50
+ expect(validate({}).errors[0].message).toMatch(/missing required 'color'/)
51
+ })
52
+
53
+ it('progressive enhancement: a naive validator ignores `$predicate`', () => {
54
+ // ignorePredicates models any standard JSON-Schema validator that doesn't
55
+ // know the keyword — it sees only `type: string`.
56
+ const naive = compilePredicateSchema(colorSchema, {
57
+ ignorePredicates: true,
58
+ })
59
+ expect(naive({ color: 'notacolor' }).valid).toBe(true) // string → passes
60
+ // …while the aware validator catches it:
61
+ expect(
62
+ compilePredicateSchema(colorSchema)({ color: 'notacolor' }).valid
63
+ ).toBe(false)
64
+ })
65
+
66
+ it('the schema is plain serializable JSON — code travels as data', () => {
67
+ const roundTripped: PredicateSchema = JSON.parse(
68
+ JSON.stringify(colorSchema)
69
+ )
70
+ expect(roundTripped).toEqual(colorSchema)
71
+ // and it still validates after a serialize/deserialize round-trip
72
+ const validate = compilePredicateSchema(roundTripped)
73
+ expect(validate({ color: '#abc' }).valid).toBe(true)
74
+ expect(validate({ color: 'nope' }).valid).toBe(false)
75
+ })
76
+
77
+ it('a `$predicate` can recurse into open nested structure', () => {
78
+ const schema: PredicateSchema = { type: 'object', $predicate: TREE }
79
+ const validate = compilePredicateSchema(schema)
80
+ expect(validate({ a: 1, b: { c: 'x', d: { e: 2 } } }).valid).toBe(true)
81
+ expect(validate({ a: 1, bad: { nope: [] } }).valid).toBe(false) // array leaf
82
+ })
83
+
84
+ it('rejects an unsafe predicate at compile time (IO never embeds)', () => {
85
+ const evil: PredicateSchema = {
86
+ type: 'string',
87
+ $predicate: `function check(v){ return fetch(v) }`,
88
+ }
89
+ expect(() => compilePredicateSchema(evil)).toThrow(/Not predicate-safe/)
90
+ })
91
+
92
+ it('one-shot helper works too', () => {
93
+ expect(validatePredicateSchema(colorSchema, { color: '#fff' }).valid).toBe(
94
+ true
95
+ )
96
+ })
97
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Predicate-aware JSON-Schema — "the missing computational half."
3
+ *
4
+ * A normal JSON-Schema node may carry a `$predicate` keyword whose value is the
5
+ * *source* of a predicate cluster (a few pure functions; the last is the entry,
6
+ * takes the value being validated, returns boolean). The source is trivially
7
+ * serializable (it's a string), and the predicate verifier (`./predicate`) is
8
+ * what makes it *safe to embed and run*: no IO, no async, fuel-bounded.
9
+ *
10
+ * Progressive enhancement falls out for free:
11
+ * - A *naive* JSON-Schema validator ignores the unknown `$predicate` keyword
12
+ * and validates only the structural part (`type`, `properties`, …).
13
+ * - A *predicate-aware* validator (this module — and, in production, an
14
+ * incoming `tosijs-schema`) ALSO runs the `$predicate`, validating the value
15
+ * grammar that JSON-Schema and TS can't express (var()/calc()/!important,
16
+ * order-flexible shorthands, recursive structure).
17
+ *
18
+ * This is the reference evaluator that lives with the predicate engine; the
19
+ * structural subset is intentionally small (type/properties/required/items) —
20
+ * full JSON-Schema structural validation is `tosijs-schema`'s job. The novel
21
+ * part is `$predicate`.
22
+ */
23
+ import { verifyPredicate, compilePredicate } from './predicate'
24
+ import type { CompilePredicateOptions } from './predicate'
25
+
26
+ /** A JSON-Schema node, optionally carrying a `$predicate` (predicate source). */
27
+ export interface PredicateSchema {
28
+ type?:
29
+ | 'string'
30
+ | 'number'
31
+ | 'integer'
32
+ | 'boolean'
33
+ | 'object'
34
+ | 'array'
35
+ | 'null'
36
+ properties?: Record<string, PredicateSchema>
37
+ required?: string[]
38
+ items?: PredicateSchema
39
+ /**
40
+ * Predicate cluster source. The LAST top-level function is the entry; it
41
+ * receives the value at this node and returns a boolean. Ignored by naive
42
+ * validators (it's a custom keyword); run by predicate-aware ones.
43
+ */
44
+ $predicate?: string
45
+ // description, title, examples, etc. are allowed and ignored.
46
+ [k: string]: unknown
47
+ }
48
+
49
+ export interface SchemaError {
50
+ /** JSON-pointer-ish path to the offending value, e.g. `/style/color`. */
51
+ path: string
52
+ message: string
53
+ }
54
+
55
+ export interface SchemaValidationResult {
56
+ valid: boolean
57
+ errors: SchemaError[]
58
+ }
59
+
60
+ export interface PredicateSchemaOptions extends CompilePredicateOptions {
61
+ /**
62
+ * Validate structure only — skip every `$predicate` (i.e. behave like a naive
63
+ * JSON-Schema validator). Useful for demonstrating progressive enhancement.
64
+ */
65
+ ignorePredicates?: boolean
66
+ }
67
+
68
+ const typeOf = (v: unknown): string =>
69
+ v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v
70
+
71
+ function typeMatches(value: unknown, type: string): boolean {
72
+ if (type === 'integer')
73
+ return typeof value === 'number' && Number.isInteger(value)
74
+ return typeOf(value) === type
75
+ }
76
+
77
+ /**
78
+ * Compile a predicate-aware JSON-Schema into a reusable validator. Every
79
+ * `$predicate` cluster is verified + compiled once (fuel-bounded, IO-rejected);
80
+ * an invalid/unsafe predicate throws here, at compile time.
81
+ */
82
+ export function compilePredicateSchema(
83
+ schema: PredicateSchema,
84
+ opts: PredicateSchemaOptions = {}
85
+ ): (value: unknown) => SchemaValidationResult {
86
+ const { ignorePredicates, ...compileOpts } = opts
87
+ // Compile every distinct $predicate once.
88
+ const compiled = new Map<string, (value: unknown) => boolean>()
89
+ const prepare = (node: PredicateSchema) => {
90
+ if (
91
+ node.$predicate &&
92
+ !ignorePredicates &&
93
+ !compiled.has(node.$predicate)
94
+ ) {
95
+ const src = node.$predicate
96
+ const names = verifyPredicate(src, compileOpts).predicates
97
+ const entry = names[names.length - 1]
98
+ if (!entry)
99
+ throw new Error('$predicate must declare at least one function')
100
+ const mod = compilePredicate(src, [entry], compileOpts)
101
+ compiled.set(src, mod[entry] as (value: unknown) => boolean)
102
+ }
103
+ if (node.properties)
104
+ for (const child of Object.values(node.properties)) prepare(child)
105
+ if (node.items) prepare(node.items)
106
+ }
107
+ prepare(schema)
108
+
109
+ const check = (
110
+ node: PredicateSchema,
111
+ value: unknown,
112
+ path: string,
113
+ errors: SchemaError[]
114
+ ) => {
115
+ if (node.type && !typeMatches(value, node.type)) {
116
+ errors.push({
117
+ path: path || '/',
118
+ message: `expected ${node.type}, got ${typeOf(value)}`,
119
+ })
120
+ return // structural mismatch — don't run value-grammar checks on it
121
+ }
122
+ if (
123
+ node.type === 'object' &&
124
+ node.properties &&
125
+ value &&
126
+ typeof value === 'object'
127
+ ) {
128
+ const obj = value as Record<string, unknown>
129
+ for (const key of node.required ?? []) {
130
+ if (!(key in obj))
131
+ errors.push({
132
+ path: `${path}/${key}`,
133
+ message: `missing required '${key}'`,
134
+ })
135
+ }
136
+ for (const [key, child] of Object.entries(node.properties)) {
137
+ if (key in obj) check(child, obj[key], `${path}/${key}`, errors)
138
+ }
139
+ }
140
+ if (node.type === 'array' && node.items && Array.isArray(value)) {
141
+ value.forEach((el, i) => check(node.items!, el, `${path}/${i}`, errors))
142
+ }
143
+ // The computational half — only the aware validator runs it.
144
+ if (node.$predicate && !ignorePredicates) {
145
+ const fn = compiled.get(node.$predicate)!
146
+ if (!fn(value))
147
+ errors.push({
148
+ path: path || '/',
149
+ message: `failed predicate at ${path || '/'}`,
150
+ })
151
+ }
152
+ }
153
+
154
+ return (value: unknown) => {
155
+ const errors: SchemaError[] = []
156
+ check(schema, value, '', errors)
157
+ return { valid: errors.length === 0, errors }
158
+ }
159
+ }
160
+
161
+ /** One-shot convenience: compile + validate. */
162
+ export function validatePredicateSchema(
163
+ schema: PredicateSchema,
164
+ value: unknown,
165
+ opts?: PredicateSchemaOptions
166
+ ): SchemaValidationResult {
167
+ return compilePredicateSchema(schema, opts)(value)
168
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import {
3
+ verifyPredicate,
4
+ compilePredicate,
5
+ effectfulFromAtoms,
6
+ PredicateFuelExhausted,
7
+ } from './predicate'
8
+ import { coreAtoms } from '../vm/runtime'
9
+
10
+ const ok = (src: string, opts?: any) => verifyPredicate(src, opts).safe
11
+ const why = (src: string, opts?: any) =>
12
+ verifyPredicate(src, opts).diagnostics.map((d) => d.message)
13
+
14
+ describe('verifyPredicate — accepts the pure substrate', () => {
15
+ it('pure expression + composition + recursion', () => {
16
+ expect(
17
+ ok(`
18
+ function isShort(s) { return typeof s == 'string' && s.length < 10 }
19
+ function isTag(s) { return isShort(s) && s.startsWith('#') }
20
+ function depth(o) {
21
+ if (typeof o != 'object' || o == null) { return 0 }
22
+ return Object.keys(o).every(isTag) ? 1 : depth(o) // self-ref ok
23
+ }
24
+ `)
25
+ ).toBe(true)
26
+ })
27
+
28
+ it('pure builtins: regex .test, string/array methods, pure namespaces', () => {
29
+ expect(ok(`function f(v){ return /^#[0-9a-f]{3}$/i.test(v) }`)).toBe(true)
30
+ expect(
31
+ ok(
32
+ `function f(xs){ return xs.every(isThing) } function isThing(x){ return x.length > 0 }`
33
+ )
34
+ ).toBe(true)
35
+ expect(ok(`function f(o){ return Object.entries(o).length > 0 }`)).toBe(
36
+ true
37
+ )
38
+ expect(ok(`function f(a,b){ return Math.max(a,b) }`)).toBe(true)
39
+ expect(ok(`function f(s){ return JSON.parse(s) }`)).toBe(true)
40
+ })
41
+ })
42
+
43
+ describe('verifyPredicate — rejects impurity (the tightened checks)', () => {
44
+ it('async / await', () => {
45
+ expect(ok(`async function f(v){ return await g(v) }`)).toBe(false)
46
+ })
47
+ it('new', () => {
48
+ expect(ok(`function f(v){ return new RegExp(v).test(v) }`)).toBe(false)
49
+ })
50
+ it('IO globals (fetch / console)', () => {
51
+ expect(why(`function f(v){ return fetch(v) }`)[0]).toMatch(
52
+ /fetch.*effectful/
53
+ )
54
+ expect(why(`function f(v){ console.log(v); return true }`)[0]).toMatch(
55
+ /console\.log.*effectful/
56
+ )
57
+ })
58
+ it('nondeterministic statics (Date.now / Math.random) — caught by the whitelist', () => {
59
+ expect(why(`function f(){ return Date.now() }`)[0]).toMatch(
60
+ /Date.*effectful|nondeterministic/
61
+ )
62
+ expect(why(`function f(){ return Math.random() }`)[0]).toMatch(
63
+ /Math\.random.*nondeterministic/
64
+ )
65
+ })
66
+ it('non-pure instance methods (.then / .push)', () => {
67
+ // .then would let a Promise in; .push mutates — neither is a known pure method
68
+ expect(why(`function f(p){ return p.then(g) }`)[0]).toMatch(
69
+ /\.then\(\).*not a known pure method/
70
+ )
71
+ expect(why(`function f(a){ a.push(1); return a }`)[0]).toMatch(
72
+ /\.push\(\).*not a known pure method/
73
+ )
74
+ })
75
+ it('unknown reference (typo / undeclared)', () => {
76
+ expect(why(`function f(v){ return notDefinedAnywhere(v) }`)[0]).toMatch(
77
+ /unknown reference/
78
+ )
79
+ })
80
+ })
81
+
82
+ describe('verifyPredicate — driven by the real atom `effects` tag', () => {
83
+ const effectful = effectfulFromAtoms(coreAtoms as any)
84
+
85
+ it('rejects a predicate calling a real io-tagged atom, with a clear message', () => {
86
+ const r = verifyPredicate(`function isUp(u){ return httpFetch(u) }`, {
87
+ effectful,
88
+ })
89
+ expect(r.safe).toBe(false)
90
+ expect(r.diagnostics[0].message).toMatch(/httpFetch.*effectful/)
91
+ expect(r.diagnostics[0].line).toBeGreaterThan(0)
92
+ })
93
+
94
+ it('still allows pure atoms / composition', () => {
95
+ expect(
96
+ verifyPredicate(`function f(s){ return s.length < 5 }`, { effectful })
97
+ .safe
98
+ ).toBe(true)
99
+ })
100
+ })
101
+
102
+ describe('verifyPredicate — cross-source composition (knownPredicates)', () => {
103
+ it('accepts a reference to an externally-verified predicate', () => {
104
+ expect(
105
+ ok(`function isUser(u){ return isEmail(u.email) }`, {
106
+ knownPredicates: new Set(['isEmail']),
107
+ })
108
+ ).toBe(true)
109
+ // …but not an unknown one
110
+ expect(ok(`function isUser(u){ return isEmail(u.email) }`)).toBe(false)
111
+ })
112
+ })
113
+
114
+ describe('verifyPredicate — rejects loops (#3: keeps work fuel-bounded)', () => {
115
+ it('while / for / for-of are rejected; recursion + array methods are not', () => {
116
+ expect(why(`function f(n){ while(n>0){ n=n-1 } return n }`)[0]).toMatch(
117
+ /loops are not allowed/
118
+ )
119
+ expect(ok(`function f(n){ for(let i=0;i<n;i++){} return n }`)).toBe(false)
120
+ expect(ok(`function f(a){ for(const x of a){} return a }`)).toBe(false)
121
+ // the sanctioned forms still pass:
122
+ expect(
123
+ ok(
124
+ `function f(a){ return a.every(isShort) } function isShort(s){ return s.length<5 }`
125
+ )
126
+ ).toBe(true)
127
+ })
128
+ })
129
+
130
+ describe('compilePredicate — fuel-bounded + global-shadowed (#3)', () => {
131
+ it('verified predicates compile and run; IO ones throw at definition time', () => {
132
+ const m = compilePredicate(
133
+ `function isHex(v){ return typeof v == 'string' && /^#[0-9a-f]{3,8}$/i.test(v) }
134
+ function isVar(v){ return typeof v == 'string' && v.startsWith('var(--') && v.endsWith(')') }
135
+ function isColor(v){ return isHex(v) || isVar(v) }`,
136
+ ['isColor']
137
+ )
138
+ expect(m.isColor('#3a3')).toBe(true)
139
+ expect(m.isColor('var(--brand)')).toBe(true)
140
+ expect(m.isColor('nope')).toBe(false)
141
+
142
+ expect(() =>
143
+ compilePredicate(`function f(v){ return fetch(v) }`, ['f'])
144
+ ).toThrow(/Not predicate-safe/)
145
+ })
146
+
147
+ it('runaway recursion exhausts fuel instead of hanging', () => {
148
+ // verifier allows recursion (call-bounded); the runtime fuel stops it.
149
+ const m = compilePredicate(`function loop(n){ return loop(n) }`, ['loop'], {
150
+ fuel: 5000,
151
+ })
152
+ expect(() => m.loop(1)).toThrow(PredicateFuelExhausted)
153
+ })
154
+
155
+ it('a huge array exhausts fuel via the per-element callback', () => {
156
+ const m = compilePredicate(
157
+ `function isPos(x){ return x > 0 }
158
+ function allPos(a){ return a.every(isPos) }`,
159
+ ['allPos'],
160
+ { fuel: 1000 }
161
+ )
162
+ expect(m.allPos([1, 2, 3])).toBe(true) // small input fine
163
+ const big = Array.from({ length: 100000 }, () => 1)
164
+ expect(() => m.allPos(big)).toThrow(PredicateFuelExhausted)
165
+ })
166
+
167
+ it('each top-level call gets a fresh budget (no cross-call starvation)', () => {
168
+ const m = compilePredicate(
169
+ `function rec(n){ if (n <= 0) { return true } return rec(n - 1) }`,
170
+ ['rec'],
171
+ { fuel: 10000 }
172
+ )
173
+ // 500 deep, well under budget — and repeatable because budget resets
174
+ for (let i = 0; i < 5; i++) expect(m.rec(500)).toBe(true)
175
+ })
176
+
177
+ it('shadows effectful globals to undefined (defense-in-depth)', () => {
178
+ // even if a global slipped past the verifier, it is undefined at runtime
179
+ const m = compilePredicate(`function usesGlobal(){ return typeof fetch }`, [
180
+ 'usesGlobal',
181
+ ])
182
+ expect(m.usesGlobal()).toBe('undefined')
183
+ })
184
+ })