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,114 @@
1
+ /**
2
+ * Drives the REAL completion source the playground uses (tjsCompletionSource),
3
+ * headlessly — its APIs (matchBefore / state.doc / pos / explicit) are DOM-free.
4
+ *
5
+ * Regression for the bug where the regex extractor missed destructuring, so the
6
+ * tosijs todo example (everything bound via destructuring) suggested nothing.
7
+ */
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { EditorState } from '@codemirror/state'
10
+ import { CompletionContext } from '@codemirror/autocomplete'
11
+ import { tjsCompletionSource } from './ajs-language'
12
+ import type { AutocompleteConfig } from './ajs-language'
13
+
14
+ const TODO = `import { elements, tosi } from 'tosijs'
15
+ const { todoApp } = tosi({ todoApp: { items: [], newItem: '' } })
16
+ const { h1, ul, template, li, label, input, button } = elements
17
+ `
18
+
19
+ async function optionsFor(
20
+ source: string,
21
+ pos: number = source.length,
22
+ config: AutocompleteConfig = {}
23
+ ) {
24
+ const state = EditorState.create({ doc: source })
25
+ const ctx = new CompletionContext(state, pos, /* explicit */ true)
26
+ const result = await tjsCompletionSource(config)(ctx)
27
+ return result?.options ?? []
28
+ }
29
+ const labelsFor = async (...args: Parameters<typeof optionsFor>) =>
30
+ (await optionsFor(...args)).map((o) => o.label)
31
+
32
+ describe('tjsCompletionSource — scope-aware locals (live provider)', () => {
33
+ it('suggests destructured bindings from the real todo example', async () => {
34
+ const labels = await labelsFor(TODO)
35
+ expect(labels).toContain('todoApp')
36
+ expect(labels).toContain('h1')
37
+ expect(labels).toContain('button')
38
+ expect(labels).toContain('elements')
39
+ })
40
+
41
+ it('completes `tod` to todoApp', async () => {
42
+ expect(await labelsFor(TODO + 'tod')).toContain('todoApp')
43
+ })
44
+
45
+ it('annotates a destructured binding with its source (∈ elements)', async () => {
46
+ const opts = await optionsFor(TODO)
47
+ expect(opts.find((o) => o.label === 'h1')?.detail).toBe('∈ elements')
48
+ })
49
+ })
50
+
51
+ describe('tjsCompletionSource — member completion via live bindings (1b-i)', () => {
52
+ // Stand in for the user's executed scope, live (resolved imports path).
53
+ const liveBindings = {
54
+ todoApp: { items: ['bathe the cat'], newItem: '', addItem() {} },
55
+ }
56
+ const member = (tail: string) =>
57
+ labelsFor(`const x = 1\n${tail}`, undefined, {
58
+ getLiveBindings: () => liveBindings,
59
+ })
60
+
61
+ it('todoApp. → its real properties (items, newItem, addItem)', async () => {
62
+ const labels = await member('todoApp.')
63
+ expect(labels).toContain('items')
64
+ expect(labels).toContain('newItem')
65
+ expect(labels).toContain('addItem')
66
+ })
67
+
68
+ it('todoApp.items. → array methods (push, map) — nested path resolves', async () => {
69
+ const labels = await member('todoApp.items.')
70
+ expect(labels).toContain('push')
71
+ expect(labels).toContain('map')
72
+ })
73
+
74
+ it('unknown path resolves to nothing (no crash)', async () => {
75
+ expect(await member('todoApp.nope.')).toEqual([])
76
+ })
77
+ })
78
+
79
+ describe('tjsCompletionSource — member completion via the bridge (1b-ii)', () => {
80
+ // The introspection bridge: async, returns the REAL runtime members for a path
81
+ // the sync bindings don't know about (the user's own executed scope).
82
+ const getMembers = async (path: string) => {
83
+ if (path === 'todoApp')
84
+ return [
85
+ { label: 'items', type: 'property' as const, detail: 'object' },
86
+ { label: 'addItem', type: 'method' as const, detail: '()' },
87
+ ]
88
+ if (path === 'todoApp.items')
89
+ return [{ label: 'push', type: 'method' as const, detail: '(arg1)' }]
90
+ return []
91
+ }
92
+
93
+ it('falls back to the bridge when sync bindings miss the path', async () => {
94
+ const labels = await labelsFor('const x = 1\ntodoApp.', undefined, {
95
+ getMembers,
96
+ })
97
+ expect(labels).toContain('items')
98
+ expect(labels).toContain('addItem')
99
+ })
100
+
101
+ it('bridge resolves a nested path (todoApp.items.push)', async () => {
102
+ const labels = await labelsFor('const x = 1\ntodoApp.items.', undefined, {
103
+ getMembers,
104
+ })
105
+ expect(labels).toContain('push')
106
+ })
107
+
108
+ it('a method member becomes a callable completion', async () => {
109
+ const opts = await optionsFor('const x = 1\ntodoApp.', undefined, {
110
+ getMembers,
111
+ })
112
+ expect(opts.find((o) => o.label === 'addItem')?.type).toBe('method')
113
+ })
114
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { introspectValue, INTROSPECT_VALUE_SOURCE } from './introspect-value'
3
+
4
+ const labels = (v: unknown) => introspectValue(v).map((m) => m.label)
5
+ const member = (v: unknown, name: string) =>
6
+ introspectValue(v).find((m) => m.label === name)
7
+
8
+ describe('introspectValue — serializable runtime introspection', () => {
9
+ it('object → own properties and methods, typed', () => {
10
+ const todoApp = { items: [], newItem: '', addItem() {} }
11
+ const ls = labels(todoApp)
12
+ expect(ls).toContain('items')
13
+ expect(ls).toContain('newItem')
14
+ expect(ls).toContain('addItem')
15
+ expect(member(todoApp, 'addItem')?.type).toBe('method')
16
+ expect(member(todoApp, 'newItem')?.type).toBe('property')
17
+ expect(member(todoApp, 'newItem')?.detail).toBe('string')
18
+ })
19
+
20
+ it('array → inherited methods from the prototype (push, map)', () => {
21
+ const ls = labels([1, 2, 3])
22
+ expect(ls).toContain('push')
23
+ expect(ls).toContain('map')
24
+ expect(ls).toContain('filter')
25
+ expect(member([1], 'push')?.type).toBe('method')
26
+ })
27
+
28
+ it('method arity becomes an arg hint', () => {
29
+ const o = { f(a: unknown, b: unknown) {} }
30
+ void o.f
31
+ expect(member(o, 'f')?.detail).toBe('(arg1, arg2)')
32
+ })
33
+
34
+ it('skips constructor and underscore-prefixed members', () => {
35
+ const ls = labels({ _private: 1, visible: 2 })
36
+ expect(ls).toContain('visible')
37
+ expect(ls).not.toContain('_private')
38
+ expect(ls).not.toContain('constructor')
39
+ })
40
+
41
+ it('introspects a Proxy via own keys (tosijs `elements` shape)', () => {
42
+ // a proxy that materialises keys on access but DOES report own keys
43
+ const backing: Record<string, unknown> = { h1: () => {}, div: () => {} }
44
+ const proxy = new Proxy(backing, {})
45
+ const ls = labels(proxy)
46
+ expect(ls).toContain('h1')
47
+ expect(ls).toContain('div')
48
+ })
49
+
50
+ it('non-objects → nothing; never throws', () => {
51
+ expect(introspectValue(null)).toEqual([])
52
+ expect(introspectValue(42)).toEqual([])
53
+ expect(introspectValue(undefined)).toEqual([])
54
+ })
55
+
56
+ it('is self-contained source (injectable into the sandbox iframe)', () => {
57
+ // no outer references that would break when eval-ed in another realm
58
+ expect(INTROSPECT_VALUE_SOURCE).toContain('function introspectValue')
59
+ expect(INTROSPECT_VALUE_SOURCE).not.toMatch(/\bimport\b/)
60
+ })
61
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Serializable runtime introspection — the in-sandbox half of the introspection
3
+ * bridge. Given a live value, produce a flat list of member descriptors that
4
+ * survive `postMessage` (plain strings, no functions). The introspection bridge
5
+ * injects this function's SOURCE into the sandbox iframe (via `.toString()`), so
6
+ * it must be **self-contained** — no imports, no outer references.
7
+ *
8
+ * It mirrors the editor's own `introspectObject` (own keys first — important for
9
+ * proxies that cache accesses, like tosijs `elements` — then the prototype
10
+ * chain), but returns data instead of CodeMirror completions. The provider maps
11
+ * these descriptors back into completions on the parent side.
12
+ */
13
+
14
+ export interface IntrospectMember {
15
+ label: string
16
+ /** 'method' for callables, else 'property'. */
17
+ type: 'method' | 'property'
18
+ /** typeof for properties, an arg hint for methods. */
19
+ detail: string
20
+ }
21
+
22
+ export function introspectValue(value: unknown): IntrospectMember[] {
23
+ if (
24
+ value == null ||
25
+ (typeof value !== 'object' && typeof value !== 'function')
26
+ ) {
27
+ return []
28
+ }
29
+
30
+ const out: IntrospectMember[] = []
31
+ const seen = new Set<string>()
32
+
33
+ const push = (key: string, v: unknown) => {
34
+ if (key === 'constructor' || key.startsWith('_') || seen.has(key)) return
35
+ seen.add(key)
36
+ if (typeof v === 'function') {
37
+ const arity = (v as { length?: number }).length ?? 0
38
+ const params =
39
+ arity > 0
40
+ ? Array.from({ length: arity }, (_, i) => `arg${i + 1}`).join(', ')
41
+ : ''
42
+ out.push({ label: key, type: 'method', detail: `(${params})` })
43
+ } else {
44
+ out.push({ label: key, type: 'property', detail: typeof v })
45
+ }
46
+ }
47
+
48
+ // Own enumerable keys first — handles Proxies that materialise on access.
49
+ try {
50
+ for (const key of Object.keys(value as object)) {
51
+ try {
52
+ push(key, (value as Record<string, unknown>)[key])
53
+ } catch {
54
+ /* getter threw — skip */
55
+ }
56
+ }
57
+ } catch {
58
+ /* Object.keys failed — continue with the prototype walk */
59
+ }
60
+
61
+ // Own properties + prototype chain (inherited methods like Array#push).
62
+ let current: any = value
63
+ while (
64
+ current &&
65
+ current !== Object.prototype &&
66
+ current !== Function.prototype
67
+ ) {
68
+ for (const key of Object.getOwnPropertyNames(current)) {
69
+ if (key === 'constructor' || key.startsWith('_') || seen.has(key))
70
+ continue
71
+ try {
72
+ const d = Object.getOwnPropertyDescriptor(current, key)
73
+ const v = d?.value ?? (d?.get ? '[getter]' : undefined)
74
+ push(key, v)
75
+ } catch {
76
+ /* property threw on access — skip */
77
+ }
78
+ }
79
+ current = Object.getPrototypeOf(current)
80
+ }
81
+
82
+ return out
83
+ }
84
+
85
+ /** The function source, for injection into the sandbox iframe. */
86
+ export const INTROSPECT_VALUE_SOURCE = introspectValue.toString()
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { collectScopeSymbols, type ScopeSymbol } from './scope-symbols'
3
+
4
+ // `|` marks the cursor; returns source without it + the offset.
5
+ function at(src: string): { source: string; position: number } {
6
+ const position = src.indexOf('|')
7
+ if (position === -1) return { source: src, position: src.length }
8
+ return { source: src.slice(0, position) + src.slice(position + 1), position }
9
+ }
10
+
11
+ const names = (syms: ScopeSymbol[]) => syms.map((s) => s.name).sort()
12
+ const find = (syms: ScopeSymbol[], n: string) => syms.find((s) => s.name === n)
13
+
14
+ describe('collectScopeSymbols — destructuring-aware scope', () => {
15
+ it('object destructuring from a call (the bug: `const { todoApp } = tosi(...)`)', () => {
16
+ const { source } = at(`const { todoApp } = tosi({ items: [] })`)
17
+ const syms = collectScopeSymbols(source)
18
+ expect(names(syms)).toContain('todoApp')
19
+ expect(find(syms, 'todoApp')?.origin?.via).toBe('destructure')
20
+ expect(find(syms, 'todoApp')?.origin?.expr).toBe('tosi({ items: [] })')
21
+ })
22
+
23
+ it('multiple names destructured from an identifier carry member + source', () => {
24
+ const { source } = at(`const { h1, ul, button } = elements`)
25
+ const syms = collectScopeSymbols(source)
26
+ expect(names(syms)).toEqual(['button', 'h1', 'ul'])
27
+ // origin is the introspection hook: `elements.h1`
28
+ expect(find(syms, 'h1')?.origin?.expr).toBe('elements')
29
+ expect(find(syms, 'h1')?.origin?.member).toBe('h1')
30
+ })
31
+
32
+ it('array destructuring, holes skipped', () => {
33
+ const { source } = at(`const [first, , third] = items`)
34
+ expect(names(collectScopeSymbols(source))).toEqual(['first', 'third'])
35
+ })
36
+
37
+ it('rename binds the new name, records the source key', () => {
38
+ const { source } = at(`const { color: c } = theme`)
39
+ const syms = collectScopeSymbols(source)
40
+ expect(names(syms)).toEqual(['c'])
41
+ expect(find(syms, 'c')?.origin?.member).toBe('color')
42
+ })
43
+
44
+ it('defaults and rest', () => {
45
+ const a = collectScopeSymbols(at(`const { x = 1, ...rest } = o`).source)
46
+ expect(names(a)).toEqual(['rest', 'x'])
47
+ const b = collectScopeSymbols(at(`const [head, ...tail] = xs`).source)
48
+ expect(names(b)).toEqual(['head', 'tail'])
49
+ })
50
+
51
+ it('imports (named, default, namespace, renamed)', () => {
52
+ const { source } = at(
53
+ `import def, { tosi, elements as els } from 'tosijs'\nimport * as ns from 'x'`
54
+ )
55
+ const syms = collectScopeSymbols(source)
56
+ expect(names(syms)).toEqual(['def', 'els', 'ns', 'tosi'])
57
+ expect(find(syms, 'tosi')?.origin?.module).toBe('tosijs')
58
+ expect(find(syms, 'els')?.kind).toBe('import')
59
+ })
60
+
61
+ it('function name in scope; params only inside the body', () => {
62
+ const inside = at(`function ship(to, qty) {\n |\n}`)
63
+ const syms = collectScopeSymbols(inside.source, inside.position)
64
+ expect(names(syms)).toEqual(['qty', 'ship', 'to'])
65
+
66
+ const outside = at(`function ship(to, qty) {}\n|`)
67
+ const syms2 = collectScopeSymbols(outside.source, outside.position)
68
+ expect(names(syms2)).toContain('ship')
69
+ expect(names(syms2)).not.toContain('to')
70
+ })
71
+
72
+ it('destructured params too', () => {
73
+ const inside = at(`function render({ title, items }) {\n |\n}`)
74
+ const syms = collectScopeSymbols(inside.source, inside.position)
75
+ expect(names(syms)).toEqual(['items', 'render', 'title'])
76
+ })
77
+
78
+ it('position-scoped: declarations after the cursor are excluded', () => {
79
+ const { source, position } = at(`const before = 1\n|\nconst after = 2`)
80
+ const syms = collectScopeSymbols(source, position)
81
+ expect(names(syms)).toContain('before')
82
+ expect(names(syms)).not.toContain('after')
83
+ })
84
+
85
+ it('resilient to mid-edit / not-yet-valid source (acorn-loose)', () => {
86
+ // trailing incomplete line shouldn't blank out the earlier bindings
87
+ const { source, position } = at(`const { todoApp } = tosi({})\ntodoApp.i|`)
88
+ const syms = collectScopeSymbols(source, position)
89
+ expect(names(syms)).toContain('todoApp')
90
+ })
91
+
92
+ it('the real tosijs todo example surfaces every binding', () => {
93
+ const src = `import { elements, tosi } from 'tosijs'
94
+ const { todoApp } = tosi({ todoApp: { items: [], newItem: '' } })
95
+ const { h1, ul, template, li, label, input, button } = elements
96
+ `
97
+ const got = names(collectScopeSymbols(src))
98
+ for (const n of [
99
+ 'elements',
100
+ 'tosi',
101
+ 'todoApp',
102
+ 'h1',
103
+ 'ul',
104
+ 'template',
105
+ 'li',
106
+ 'label',
107
+ 'input',
108
+ 'button',
109
+ ]) {
110
+ expect(got).toContain(n)
111
+ }
112
+ })
113
+ })
@@ -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.3",
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
+ })