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.
- package/CLAUDE.md +4 -1
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +698 -8
- package/demo/src/autocomplete.ts +40 -2
- package/demo/src/introspection-bridge.ts +140 -0
- package/demo/src/introspection-doc.test.ts +63 -0
- package/demo/src/playground-shared.ts +53 -0
- package/demo/src/tjs-playground.ts +21 -0
- package/editors/codemirror/ajs-language.ts +130 -44
- package/editors/codemirror/completion-source.test.ts +114 -0
- package/editors/introspect-value.test.ts +61 -0
- package/editors/introspect-value.ts +86 -0
- package/editors/scope-symbols.test.ts +113 -0
- package/editors/scope-symbols.ts +173 -0
- package/package.json +2 -1
- package/src/lang/index.ts +22 -0
- package/src/lang/predicate-schema.test.ts +97 -0
- package/src/lang/predicate-schema.ts +168 -0
- package/src/lang/predicate.test.ts +184 -0
- package/src/lang/predicate.ts +550 -0
- package/src/lang/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +26 -0
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +12 -0
- package/src/vm/runtime.ts +51 -0
|
@@ -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
|
+
})
|