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.
- package/CLAUDE.md +5 -2
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +701 -11
- 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/dist/experiments/predicates/css.predicates.d.ts +13 -0
- package/dist/experiments/predicates/verify.d.ts +39 -0
- package/dist/index.js +101 -97
- package/dist/index.js.map +4 -4
- package/dist/src/lang/index.d.ts +2 -0
- package/dist/src/lang/predicate-schema.d.ts +39 -0
- package/dist/src/lang/predicate.d.ts +103 -0
- package/dist/src/lang/transpiler.d.ts +2 -0
- package/dist/src/vm/runtime.d.ts +22 -0
- package/dist/tjs-eval.js +29 -29
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +86 -82
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +45 -45
- package/dist/tjs-vm.js.map +3 -3
- 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/runtime.test.ts +46 -0
- package/src/lang/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +26 -0
- package/src/runtime.test.ts +6 -6
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +12 -0
- package/src/vm/equality.test.ts +59 -0
- 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.
|
|
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
|
+
})
|