tjs-lang 0.8.2 → 0.8.3

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.
@@ -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
+ })