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,550 @@
1
+ /**
2
+ * Predicate-safety verifier.
3
+ *
4
+ * A *predicate* is a pure, synchronous function of its inputs. A cluster of
5
+ * predicates is **predicate-safe** iff every function in it uses only pure
6
+ * constructs and calls only pure builtins, pure globals, or other
7
+ * predicate-safe predicates — a closure property, so the cluster is safe iff
8
+ * each function is. Verified predicates have "earned" the native fast path:
9
+ * they compile to plain synchronous JS where ergonomic composition
10
+ * (`isHex(v) || isVar(v)`, `tokens.every(isToken)`, recursion) just works.
11
+ *
12
+ * This is pure static analysis over the parsed source, so it has none of the VM
13
+ * interpreter's restrictions (calls-in-expressions, named callbacks) — it
14
+ * accepts the ergonomic source and certifies it. The serializable AJS AST stays
15
+ * the portable form (the "missing computational half" of JSON Schema); native
16
+ * JS is the execution form.
17
+ *
18
+ * Effect classification of atoms comes from the VM's `effects` tag — pass
19
+ * `effectfulFromAtoms(registry)` for atom-aware checking; without it, atom calls
20
+ * still fail closed as "unknown reference". See `experiments/predicates/` for
21
+ * the CSS torture set and `src/vm/atom-effects.test.ts` for the drift guard.
22
+ */
23
+ import * as acorn from 'acorn'
24
+ import * as walk from 'acorn-walk'
25
+
26
+ export interface PredicateDiagnostic {
27
+ /** Name of the predicate the problem is in. */
28
+ predicate: string
29
+ message: string
30
+ line: number
31
+ column: number
32
+ }
33
+
34
+ export interface PredicateVerifyResult {
35
+ safe: boolean
36
+ /** Names of the top-level functions found (the predicate cluster). */
37
+ predicates: string[]
38
+ diagnostics: PredicateDiagnostic[]
39
+ }
40
+
41
+ export interface VerifyPredicateOptions {
42
+ /**
43
+ * Names that are effectful and must not be called — IO atoms + JS IO globals.
44
+ * Compose from the atom registry with `effectfulFromAtoms`. The built-in JS IO
45
+ * globals are always included.
46
+ */
47
+ effectful?: Set<string>
48
+ /**
49
+ * Externally-verified predicate names this source may compose with (a shared
50
+ * registry), in addition to the functions declared in `source`.
51
+ */
52
+ knownPredicates?: Set<string>
53
+ }
54
+
55
+ // --- the safe substrate -----------------------------------------------------
56
+
57
+ /** Pure deterministic globals callable by bare name. */
58
+ const PURE_GLOBALS = new Set([
59
+ 'parseInt',
60
+ 'parseFloat',
61
+ 'isNaN',
62
+ 'isFinite',
63
+ 'encodeURIComponent',
64
+ 'decodeURIComponent',
65
+ 'String',
66
+ 'Number',
67
+ 'Boolean',
68
+ 'Array',
69
+ 'Object',
70
+ ])
71
+
72
+ /** Namespaces whose static methods are pure (with effectful exceptions below). */
73
+ const PURE_NAMESPACES = new Set([
74
+ 'Math',
75
+ 'JSON',
76
+ 'Object',
77
+ 'Array',
78
+ 'String',
79
+ 'Number',
80
+ ])
81
+
82
+ /** Static members that are NOT pure even on a pure namespace. */
83
+ const EFFECTFUL_STATICS = new Set(['Math.random', 'Date.now'])
84
+
85
+ /**
86
+ * Instance methods known to be pure regardless of receiver type. A method call
87
+ * whose method name isn't here (and whose receiver isn't a pure namespace) is
88
+ * flagged — so `x.then()`, `obj.fetch()`, `arr.push()` (mutates) don't pass.
89
+ */
90
+ const PURE_INSTANCE_METHODS = new Set([
91
+ // string
92
+ 'startsWith',
93
+ 'endsWith',
94
+ 'includes',
95
+ 'indexOf',
96
+ 'lastIndexOf',
97
+ 'slice',
98
+ 'substring',
99
+ 'substr',
100
+ 'toLowerCase',
101
+ 'toUpperCase',
102
+ 'trim',
103
+ 'trimStart',
104
+ 'trimEnd',
105
+ 'split',
106
+ 'replace',
107
+ 'replaceAll',
108
+ 'match',
109
+ 'matchAll',
110
+ 'charAt',
111
+ 'charCodeAt',
112
+ 'codePointAt',
113
+ 'padStart',
114
+ 'padEnd',
115
+ 'repeat',
116
+ 'concat',
117
+ 'at',
118
+ 'normalize',
119
+ 'search',
120
+ 'localeCompare',
121
+ // array (non-mutating)
122
+ 'every',
123
+ 'some',
124
+ 'map',
125
+ 'filter',
126
+ 'reduce',
127
+ 'reduceRight',
128
+ 'find',
129
+ 'findIndex',
130
+ 'findLast',
131
+ 'findLastIndex',
132
+ 'flat',
133
+ 'flatMap',
134
+ 'join',
135
+ 'keys',
136
+ 'entries',
137
+ 'values',
138
+ 'forEach',
139
+ // regexp
140
+ 'test',
141
+ 'exec',
142
+ // number / shared
143
+ 'toFixed',
144
+ 'toPrecision',
145
+ 'toString',
146
+ 'valueOf',
147
+ 'hasOwnProperty',
148
+ ])
149
+
150
+ /** JS globals that perform IO / are nondeterministic / have side effects. */
151
+ const EFFECTFUL_GLOBALS = [
152
+ 'fetch',
153
+ 'XMLHttpRequest',
154
+ 'WebSocket',
155
+ 'Date',
156
+ 'console',
157
+ 'setTimeout',
158
+ 'setInterval',
159
+ 'requestAnimationFrame',
160
+ 'queueMicrotask',
161
+ 'localStorage',
162
+ 'sessionStorage',
163
+ 'indexedDB',
164
+ 'document',
165
+ 'window',
166
+ 'globalThis',
167
+ 'self',
168
+ 'process',
169
+ 'require',
170
+ 'eval',
171
+ 'Function',
172
+ 'import',
173
+ 'crypto',
174
+ 'performance',
175
+ 'navigator',
176
+ ]
177
+
178
+ /**
179
+ * Build the effectful-name set from a VM atom registry: every atom tagged
180
+ * `effects: 'io'`, plus the built-in JS IO globals. This is how the verifier
181
+ * consumes the atom-effects classification (the keystone).
182
+ */
183
+ export function effectfulFromAtoms(
184
+ atoms: Record<string, { op?: string; effects?: 'pure' | 'io' }>
185
+ ): Set<string> {
186
+ const set = new Set(EFFECTFUL_GLOBALS)
187
+ for (const [name, atom] of Object.entries(atoms)) {
188
+ if (atom?.effects === 'io') set.add(atom.op ?? name)
189
+ }
190
+ return set
191
+ }
192
+
193
+ // --- the verifier -----------------------------------------------------------
194
+
195
+ /**
196
+ * Verify every top-level function declaration in `source` is predicate-safe.
197
+ * Returns all diagnostics; `safe` is true iff there are none (closure property).
198
+ */
199
+ export function verifyPredicate(
200
+ source: string,
201
+ opts: VerifyPredicateOptions = {}
202
+ ): PredicateVerifyResult {
203
+ const effectful = opts.effectful ?? new Set(EFFECTFUL_GLOBALS)
204
+ let ast: any
205
+ try {
206
+ ast = acorn.parse(source, { ecmaVersion: 'latest', locations: true })
207
+ } catch (e: any) {
208
+ return {
209
+ safe: false,
210
+ predicates: [],
211
+ diagnostics: [
212
+ {
213
+ predicate: '<source>',
214
+ message: `parse error: ${e.message}`,
215
+ line: e.loc?.line ?? 0,
216
+ column: e.loc?.column ?? 0,
217
+ },
218
+ ],
219
+ }
220
+ }
221
+
222
+ const predicateNames = new Set<string>(opts.knownPredicates ?? [])
223
+ for (const node of ast.body) {
224
+ if (node.type === 'FunctionDeclaration' && node.id)
225
+ predicateNames.add(node.id.name)
226
+ }
227
+
228
+ const diagnostics: PredicateDiagnostic[] = []
229
+
230
+ for (const fn of ast.body) {
231
+ if (fn.type !== 'FunctionDeclaration' || !fn.id) continue
232
+ const pname = fn.id.name
233
+ const flag = (message: string, n: any) =>
234
+ diagnostics.push({
235
+ predicate: pname,
236
+ message,
237
+ line: n?.loc?.start?.line ?? 0,
238
+ column: n?.loc?.start?.column ?? 0,
239
+ })
240
+
241
+ const loop = (n: any) =>
242
+ flag(
243
+ 'loops are not allowed — iterate with recursion or array methods (every/some/map/filter/reduce) so work stays fuel-bounded',
244
+ n
245
+ )
246
+ walk.simple(fn, {
247
+ AwaitExpression(n: any) {
248
+ flag('`await` not allowed — predicates must be synchronous', n)
249
+ },
250
+ NewExpression(n: any) {
251
+ flag('`new` not allowed in a predicate (non-pure construction)', n)
252
+ },
253
+ WhileStatement: loop,
254
+ DoWhileStatement: loop,
255
+ ForStatement: loop,
256
+ ForInStatement: loop,
257
+ ForOfStatement: loop,
258
+ CallExpression(n: any) {
259
+ const callee = n.callee
260
+ // Bare call: f(...)
261
+ if (callee.type === 'Identifier') {
262
+ const name = callee.name
263
+ if (effectful.has(name))
264
+ flag(`'${name}' is effectful — not allowed in a predicate`, callee)
265
+ else if (predicateNames.has(name)) {
266
+ /* composition with another predicate — OK */
267
+ } else if (PURE_GLOBALS.has(name)) {
268
+ /* pure global — OK */
269
+ } else {
270
+ flag(
271
+ `unknown reference '${name}' — not a predicate or pure builtin`,
272
+ callee
273
+ )
274
+ }
275
+ return
276
+ }
277
+ // Method call: recv.method(...)
278
+ if (callee.type === 'MemberExpression' && !callee.computed) {
279
+ const method = callee.property.name
280
+ const recv = callee.object
281
+ if (recv.type === 'Identifier' && effectful.has(recv.name)) {
282
+ flag(`'${recv.name}.${method}' is effectful`, callee)
283
+ } else if (
284
+ recv.type === 'Identifier' &&
285
+ PURE_NAMESPACES.has(recv.name)
286
+ ) {
287
+ if (EFFECTFUL_STATICS.has(`${recv.name}.${method}`))
288
+ flag(`'${recv.name}.${method}' is nondeterministic`, callee)
289
+ // else pure namespace method — OK
290
+ } else if (!PURE_INSTANCE_METHODS.has(method)) {
291
+ flag(
292
+ `method '.${method}()' is not a known pure method`,
293
+ callee.property
294
+ )
295
+ }
296
+ return
297
+ }
298
+ // Anything else (computed member, call-of-call, etc.) — be conservative.
299
+ flag('unsupported call form in a predicate', callee)
300
+ },
301
+ })
302
+ }
303
+
304
+ return {
305
+ safe: diagnostics.length === 0,
306
+ predicates: [...predicateNames],
307
+ diagnostics,
308
+ }
309
+ }
310
+
311
+ /** Format diagnostics for a thrown error / log. */
312
+ export function formatPredicateDiagnostics(d: PredicateDiagnostic[]): string {
313
+ return d
314
+ .map((x) => ` ${x.predicate} (${x.line}:${x.column}): ${x.message}`)
315
+ .join('\n')
316
+ }
317
+
318
+ // --- suggestion mining (#4: autocomplete companion) -------------------------
319
+
320
+ export interface Suggestion {
321
+ value: string
322
+ /**
323
+ * `'value'` — a concrete candidate (a keyword/literal the predicate accepts);
324
+ * `'stub'` — a partial completion to keep typing (e.g. `var(--`, `calc(`),
325
+ * mined from a `startsWith(...)` guard. TS's `string` fallback
326
+ * offers neither; a finite TS union offers only `'value'`.
327
+ */
328
+ kind: 'value' | 'stub'
329
+ }
330
+
331
+ export interface SuggestOptions extends CompilePredicateOptions {
332
+ /** Only return candidates relevant to the text typed so far. */
333
+ prefix?: string
334
+ /** Cap the number of suggestions (default 50). */
335
+ limit?: number
336
+ /**
337
+ * Run each mined *value* through the compiled entry predicate and keep only
338
+ * those that actually pass — so completions are guaranteed valid, not merely
339
+ * enumerated. Default true when the cluster is predicate-safe; stubs are never
340
+ * validated (they're partial by construction).
341
+ */
342
+ validate?: boolean
343
+ /** Entry predicate to validate against (default: last top-level function). */
344
+ entry?: string
345
+ }
346
+
347
+ const isStringLiteral = (n: any): n is { value: string } =>
348
+ n && n.type === 'Literal' && typeof n.value === 'string'
349
+
350
+ /**
351
+ * Mine a predicate cluster's source for autocomplete candidates. Two sources:
352
+ * - **values** — string literals compared with `==`/`===` and members of
353
+ * array literals (the keyword sets a predicate checks membership against).
354
+ * - **stubs** — the argument of a `.startsWith(...)` guard, surfaced as a
355
+ * partial completion (`var(--`, `calc(`) the user keeps typing.
356
+ *
357
+ * Pure syntactic mining over the parsed source — no execution. By default the
358
+ * mined *values* are then run through the compiled entry predicate, so the
359
+ * returned set is exactly what the predicate accepts (a literal that only ever
360
+ * appears in a `!=` / negative context is dropped). This is the autocomplete
361
+ * win over a TS `string` fallback (which suggests nothing) and over a finite TS
362
+ * union (which can't offer the open-ended `var(--`/`calc(` stubs).
363
+ */
364
+ export function suggest(
365
+ source: string,
366
+ opts: SuggestOptions = {}
367
+ ): Suggestion[] {
368
+ let ast: any
369
+ try {
370
+ ast = acorn.parse(source, { ecmaVersion: 'latest' })
371
+ } catch {
372
+ return []
373
+ }
374
+
375
+ const values = new Set<string>()
376
+ const stubs = new Set<string>()
377
+ walk.simple(ast, {
378
+ BinaryExpression(n: any) {
379
+ if (n.operator === '==' || n.operator === '===') {
380
+ if (isStringLiteral(n.left)) values.add(n.left.value)
381
+ if (isStringLiteral(n.right)) values.add(n.right.value)
382
+ }
383
+ },
384
+ ArrayExpression(n: any) {
385
+ for (const el of n.elements) if (isStringLiteral(el)) values.add(el.value)
386
+ },
387
+ CallExpression(n: any) {
388
+ const callee = n.callee
389
+ if (callee.type !== 'MemberExpression' || callee.computed) return
390
+ const method = callee.property.name
391
+ const arg = n.arguments[0]
392
+ if (!isStringLiteral(arg)) return
393
+ if (method === 'startsWith') stubs.add(arg.value)
394
+ // `.includes('x')` / `.endsWith('x')` aren't standalone completions:
395
+ // includes-arg is a substring, endsWith-arg is a tail — skip both.
396
+ },
397
+ })
398
+
399
+ // Validate mined values against the predicate unless told not to / unsafe.
400
+ let accept: ((v: string) => boolean) | null = null
401
+ if (opts.validate !== false) {
402
+ const verified = verifyPredicate(source, opts)
403
+ if (verified.safe && verified.predicates.length) {
404
+ const entry =
405
+ opts.entry ?? verified.predicates[verified.predicates.length - 1]
406
+ try {
407
+ const mod = compilePredicate(source, [entry], opts)
408
+ const fn = mod[entry]
409
+ accept = (v) => {
410
+ try {
411
+ return fn(v) === true
412
+ } catch {
413
+ return false // fuel exhaustion / runtime miss → not a suggestion
414
+ }
415
+ }
416
+ } catch {
417
+ accept = null // not compilable → fall back to raw mining
418
+ }
419
+ }
420
+ }
421
+
422
+ const out: Suggestion[] = []
423
+ for (const v of values)
424
+ if (!accept || accept(v)) out.push({ value: v, kind: 'value' })
425
+ for (const s of stubs) out.push({ value: s, kind: 'stub' })
426
+
427
+ let filtered = out
428
+ if (opts.prefix) {
429
+ const p = opts.prefix
430
+ filtered = out.filter(
431
+ (s) =>
432
+ s.value.startsWith(p) || (s.kind === 'stub' && p.startsWith(s.value))
433
+ )
434
+ }
435
+ filtered.sort(
436
+ (a, b) =>
437
+ (a.kind === b.kind ? 0 : a.kind === 'value' ? -1 : 1) ||
438
+ a.value.localeCompare(b.value)
439
+ )
440
+ return opts.limit ? filtered.slice(0, opts.limit) : filtered
441
+ }
442
+
443
+ /** Thrown when a predicate exceeds its fuel budget (likely a pathological input). */
444
+ export class PredicateFuelExhausted extends Error {
445
+ constructor(budget: number) {
446
+ super(`predicate exceeded its fuel budget (${budget})`)
447
+ this.name = 'PredicateFuelExhausted'
448
+ }
449
+ }
450
+
451
+ export interface CompilePredicateOptions extends VerifyPredicateOptions {
452
+ /** Max fuel per top-level predicate call (default 1,000,000). */
453
+ fuel?: number
454
+ }
455
+
456
+ /**
457
+ * Inject `__fuel()` at every function-body entry and comma-wrap expression-body
458
+ * arrows, by splicing at source offsets (no codegen needed). Because loops are
459
+ * rejected, function-entry fuel bounds all iteration: recursion costs fuel per
460
+ * call, and array-method callbacks (`xs.every(p)`) cost fuel per element via the
461
+ * callback's own entry.
462
+ */
463
+ function injectFuel(source: string): string {
464
+ const ast = acorn.parse(source, { ecmaVersion: 'latest' }) as any
465
+ // Each edit: [offset, text]. Applied descending so offsets stay valid.
466
+ const edits: Array<[number, string]> = []
467
+ const enterBlockBody = (n: any) => edits.push([n.body.start + 1, '__fuel();'])
468
+ walk.simple(ast, {
469
+ FunctionDeclaration: enterBlockBody,
470
+ FunctionExpression: enterBlockBody,
471
+ ArrowFunctionExpression(n: any) {
472
+ if (n.body.type === 'BlockStatement') {
473
+ edits.push([n.body.start + 1, '__fuel();'])
474
+ } else {
475
+ // expression-body arrow: `x => EXPR` → `x => (__fuel(), EXPR)`
476
+ edits.push([n.body.start, '(__fuel(), '])
477
+ edits.push([n.body.end, ')'])
478
+ }
479
+ },
480
+ })
481
+ edits.sort((a, b) => b[0] - a[0])
482
+ let out = source
483
+ for (const [off, text] of edits)
484
+ out = out.slice(0, off) + text + out.slice(off)
485
+ return out
486
+ }
487
+
488
+ /**
489
+ * Verify, then compile the cluster to native synchronous JS functions —
490
+ * **fuel-bounded and global-shadowed**. Throws (with located diagnostics) at
491
+ * definition time if not predicate-safe.
492
+ *
493
+ * Each compiled predicate runs with a fresh fuel budget; a runaway input throws
494
+ * `PredicateFuelExhausted` rather than hanging. The effectful globals are
495
+ * shadowed to `undefined` as defense-in-depth beneath the static verifier.
496
+ *
497
+ * NOTE: preserves JS semantics (no structural-`==` rewrite yet — a future
498
+ * opt-in). Emission is offset-spliced source, not a full AJS-AST→JS codegen.
499
+ */
500
+ export function compilePredicate(
501
+ source: string,
502
+ exportNames: string[],
503
+ opts: CompilePredicateOptions = {}
504
+ ): Record<string, (...args: any[]) => any> {
505
+ const result = verifyPredicate(source, opts)
506
+ if (!result.safe)
507
+ throw new Error(
508
+ `Not predicate-safe:\n${formatPredicateDiagnostics(result.diagnostics)}`
509
+ )
510
+
511
+ const budget = opts.fuel ?? 1_000_000
512
+ const instrumented = injectFuel(source)
513
+
514
+ // Shadow the effectful globals to undefined (defense-in-depth under the
515
+ // verifier), and inject the fuel hook. `new Function` params shadow globals.
516
+ // Exclude reserved words that can't be parameter names (still verifier-rejected).
517
+ const shadowed = EFFECTFUL_GLOBALS.filter(
518
+ (g) => g !== 'import' && g !== 'eval' && g !== 'arguments'
519
+ )
520
+ const factory = new Function(
521
+ '__fuel',
522
+ ...shadowed,
523
+ `"use strict";\n${instrumented}\n;return { ${exportNames.join(', ')} };`
524
+ )
525
+
526
+ let fuel = 0
527
+ const fuelHook = () => {
528
+ if (--fuel < 0) throw new PredicateFuelExhausted(budget)
529
+ }
530
+ const raw = factory(fuelHook, ...shadowed.map(() => undefined))
531
+
532
+ // Each top-level call gets a fresh budget; inner composed calls share it.
533
+ // A stack overflow (deep recursion past the JS frame limit before fuel runs
534
+ // out) is the same "runaway" signal, so normalize it to PredicateFuelExhausted.
535
+ const wrapped: Record<string, (...args: any[]) => any> = {}
536
+ for (const name of exportNames) {
537
+ const fn = raw[name]
538
+ wrapped[name] = (...args: any[]) => {
539
+ fuel = budget
540
+ try {
541
+ return fn(...args)
542
+ } catch (e) {
543
+ if (e instanceof RangeError && /stack/i.test(e.message))
544
+ throw new PredicateFuelExhausted(budget)
545
+ throw e
546
+ }
547
+ }
548
+ }
549
+ return wrapped
550
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { suggest } from './predicate'
3
+
4
+ const vals = (s: ReturnType<typeof suggest>) =>
5
+ s.filter((x) => x.kind === 'value').map((x) => x.value)
6
+ const stubs = (s: ReturnType<typeof suggest>) =>
7
+ s.filter((x) => x.kind === 'stub').map((x) => x.value)
8
+
9
+ describe('suggest — autocomplete from predicate clusters', () => {
10
+ // The keyword set is a plain array literal + an equality check; the open-ended
11
+ // values ride in `startsWith` guards. TS could enumerate the union but not the
12
+ // open-ended `var(--`; a TS `string` fallback offers neither.
13
+ const ANIMATION = String.raw`
14
+ var TIMING = ['linear','ease','ease-in','ease-out','ease-in-out']
15
+ function isTime(t){ return /^-?[0-9.]+m?s$/.test(t) }
16
+ function isIter(t){ return t == 'infinite' || /^[0-9.]+$/.test(t) }
17
+ function isVar(t){ return typeof t == 'string' && t.startsWith('var(--') }
18
+ function isTok(t){ return isTime(t) || isIter(t) || TIMING.includes(t) || isVar(t) }
19
+ function isAnimationToken(v){ return typeof v == 'string' && isTok(v.trim()) }
20
+ `
21
+
22
+ it('mines the keyword set (array literal + equality literal)', () => {
23
+ const v = vals(suggest(ANIMATION))
24
+ expect(v).toContain('linear')
25
+ expect(v).toContain('ease-in-out')
26
+ expect(v).toContain('infinite') // from `t == 'infinite'`
27
+ })
28
+
29
+ it('mines open-ended `startsWith` guards as stubs (TS string can offer none)', () => {
30
+ expect(stubs(suggest(ANIMATION))).toContain('var(--')
31
+ })
32
+
33
+ it('filters by the prefix typed so far', () => {
34
+ const v = vals(suggest(ANIMATION, { prefix: 'ease-' }))
35
+ expect(v).toEqual(['ease-in', 'ease-in-out', 'ease-out'])
36
+ expect(v).not.toContain('linear')
37
+ })
38
+
39
+ it('a stub matches when the user is mid-typing into it', () => {
40
+ // user has typed `var(`; the `var(--` stub is still the relevant completion
41
+ expect(stubs(suggest(ANIMATION, { prefix: 'var(' }))).toContain('var(--')
42
+ // and once they pass it, it still matches by startsWith
43
+ expect(stubs(suggest(ANIMATION, { prefix: 'var(--' }))).toContain('var(--')
44
+ })
45
+
46
+ it('validated suggestions are exactly what the predicate accepts', () => {
47
+ // `bad` is in the keyword array but the entry predicate excludes it — so the
48
+ // mined candidate is filtered out by running it through the predicate.
49
+ const src = String.raw`
50
+ var ALL = ['yes','maybe','bad']
51
+ function check(v){ return ALL.includes(v) && v != 'bad' }
52
+ `
53
+ const v = vals(suggest(src))
54
+ expect(v).toContain('yes')
55
+ expect(v).toContain('maybe')
56
+ expect(v).not.toContain('bad') // mined, but predicate rejects it
57
+ })
58
+
59
+ it('validate:false returns raw mined candidates (no predicate run)', () => {
60
+ const src = String.raw`
61
+ var ALL = ['yes','maybe','bad']
62
+ function check(v){ return ALL.includes(v) && v != 'bad' }
63
+ `
64
+ const v = vals(suggest(src, { validate: false }))
65
+ expect(v).toContain('yes')
66
+ expect(v).toContain('bad') // un-validated → the excluded literal leaks in
67
+ })
68
+
69
+ it('respects limit', () => {
70
+ expect(suggest(ANIMATION, { limit: 2 }).length).toBe(2)
71
+ })
72
+
73
+ it('values sort before stubs', () => {
74
+ const s = suggest(ANIMATION)
75
+ const firstStub = s.findIndex((x) => x.kind === 'stub')
76
+ const lastValue = s.map((x) => x.kind).lastIndexOf('value')
77
+ expect(lastValue).toBeLessThan(firstStub)
78
+ })
79
+
80
+ it('empty / unparseable source yields nothing', () => {
81
+ expect(suggest('')).toEqual([])
82
+ expect(suggest('function (')).toEqual([])
83
+ })
84
+ })
@@ -24,6 +24,32 @@ export {
24
24
  type SourceKind,
25
25
  } from './dialect'
26
26
 
27
+ // Predicate-safety: verify a cluster of pure, synchronous, composable predicates
28
+ export {
29
+ verifyPredicate,
30
+ compilePredicate,
31
+ suggest,
32
+ effectfulFromAtoms,
33
+ formatPredicateDiagnostics,
34
+ PredicateFuelExhausted,
35
+ type PredicateDiagnostic,
36
+ type PredicateVerifyResult,
37
+ type VerifyPredicateOptions,
38
+ type CompilePredicateOptions,
39
+ type Suggestion,
40
+ type SuggestOptions,
41
+ } from './predicate'
42
+
43
+ // Predicate-aware JSON-Schema: the `$predicate` keyword (computational types)
44
+ export {
45
+ compilePredicateSchema,
46
+ validatePredicateSchema,
47
+ type PredicateSchema,
48
+ type SchemaError,
49
+ type SchemaValidationResult,
50
+ type PredicateSchemaOptions,
51
+ } from './predicate-schema'
52
+
27
53
  // AST emitter
28
54
  export { transformFunction } from './emitters/ast'
29
55