From 861aa016da46016f090e2bd98821a3a4d1856a25 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Wed, 13 May 2026 22:03:09 +0100 Subject: [PATCH 1/3] feat: context-aware functions and generic context typing Adds addContextFunction(name, fn) so registered functions can read the evaluation context as their first parameter, alongside the existing addFunction for pure functions. Makes bonsai() generic over context type, propagating end-to-end through evaluate, evaluateSync, compile, addContextFunction, and BonsaiPlugin. The context type generic is constrained to BonsaiContext (object) so primitive generics are rejected at compile time. EvaluationContextArgs makes the context argument required at call sites when TCtx has required fields and optional otherwise, catching missing-context bugs without breaking the untyped default path. Plugins typed against a narrower context apply cleanly to wider-context instances; mismatched plugins are rejected at compile time. Context functions receive a shallow-frozen copy of the context lazily created once per top-level evaluation and shared across all context-function calls within that evaluation. Pure-function call sites are unaffected; benchmarks show no regression. Closes #33. Co-authored-by: jaenyf <95911656+jaenyf@users.noreply.github.com> --- CHANGELOG.md | 17 ++ README.md | 65 +++++- benchmarks/context-functions.bench.ts | 60 +++++ benchmarks/overhead.bench.ts | 6 +- src/eval-ops.ts | 29 +++ src/evaluator-async.ts | 25 +- src/evaluator.ts | 41 +++- src/index.ts | 76 ++++-- src/plugins.ts | 91 +++++++- src/types.ts | 58 +++-- tests/context-functions-types.test.ts | 145 ++++++++++++ tests/context-functions.test.ts | 322 ++++++++++++++++++++++++++ tests/evaluator-transforms.test.ts | 2 +- tests/evaluator.test.ts | 2 +- tests/property.test.ts | 4 +- 15 files changed, 875 insertions(+), 68 deletions(-) create mode 100644 benchmarks/context-functions.bench.ts create mode 100644 tests/context-functions-types.test.ts create mode 100644 tests/context-functions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eca452..85e9177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Context-aware functions** (`addContextFunction`): register functions that receive a shallow-frozen snapshot of the evaluation context as their first parameter, enabling auth/permission/personalization patterns without threading context through expression arguments. Pure and context-aware functions share a single namespace; `isContextFunction(name)` introspects the kind. +- **Generic context typing** (`bonsai()`): the factory is now generic over context type, with end-to-end type safety through `evaluate`, `evaluateSync`, `compile`, and `addContextFunction`. Backward compatible: defaults to `Record` when unspecified. +- New exported types: `ContextFunctionFn`, generic `BonsaiPlugin`, generic `CompiledExpression`, generic `BonsaiInstance`. +- Internal `Bindings` snapshot consolidates transforms / functions / context-functions into a single cached object passed to the evaluator. + +### Resolves + +- #33: context access from registered functions. + +### Credits + +- Thanks to @jaenyf for raising #33 and contributing PR #34. + ## [0.3.0] - 2026-03-21 ### Added diff --git a/README.md b/README.md index d18d243..9df9d40 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,49 @@ expr.addFunction('lookupTier', async (userId) => { await expr.evaluate('lookupTier(userId) == "pro"', { userId: 'u_123' }) ``` +### Context-aware functions + +Register functions that read the evaluation context directly. The function +receives a shallow-frozen snapshot of the context as its first parameter, so +you can keep expressions terse and let the function pull what it needs: + +```ts +import { bonsai } from 'bonsai-js' + +interface AppContext { + currentUserId: string + perms: readonly string[] +} + +const app = bonsai() + +app.addContextFunction('lookupCurrentUserTier', async (ctx) => { + const row = await db.users.findById(ctx.currentUserId) + return row?.tier ?? 'free' +}) + +app.addContextFunction('hasPermission', (ctx, action) => + ctx.perms.includes(String(action))) + +await app.evaluate( + 'lookupCurrentUserTier() == "pro" && hasPermission("admin")', + { currentUserId: 'u_123', perms: ['admin', 'write'] }, +) +``` + +The instance is generic over the context type (`bonsai()`), giving +you end-to-end type safety: `ctx` is typed inside the function, and the call +site is type-checked against the same shape. If your context type has required +fields, TypeScript also requires you to pass the `context` argument to +`evaluate`, `evaluateSync`, and compiled-expression evaluation. The frozen +snapshot guarantees functions cannot mutate shared evaluation state. + +Pure functions (`addFunction`) and context-aware functions (`addContextFunction`) +share a single namespace. Registering the same name with either method +overwrites the prior registration. Use `isContextFunction(name)` for +introspection when needed. Plugins typed against a minimal context requirement +can still be applied to instances with a wider context shape. + ### Editor validation ```ts @@ -361,20 +404,25 @@ evaluateExpression('x * 2', { x: 21 }) // 42 ## Instance Methods ```ts -interface BonsaiInstance { - use(plugin: BonsaiPlugin): this +type EvaluationContextArgs = Record> = + {} extends TCtx ? [context?: TCtx] : [context: TCtx] + +interface BonsaiInstance = Record> { + use(plugin: BonsaiPlugin): this addTransform(name: string, fn: TransformFn): this addFunction(name: string, fn: FunctionFn): this + addContextFunction(name: string, fn: ContextFunctionFn): this removeTransform(name: string): boolean removeFunction(name: string): boolean hasTransform(name: string): boolean hasFunction(name: string): boolean + isContextFunction(name: string): boolean listTransforms(): string[] listFunctions(): string[] clearCache(): void - compile(expression: string): CompiledExpression - evaluate(expression: string, context?: Record): Promise - evaluateSync(expression: string, context?: Record): T + compile(expression: string): CompiledExpression + evaluate(expression: string, ...args: EvaluationContextArgs): Promise + evaluateSync(expression: string, ...args: EvaluationContextArgs): T validate(expression: string): ValidationResult } ``` @@ -382,9 +430,12 @@ interface BonsaiInstance { Method notes: - `use()` runs a plugin immediately and returns the same instance. -- `addTransform()` and `addFunction()` overwrite any existing registration with the same name. -- `listTransforms()` and `listFunctions()` return the currently registered names. +- `addTransform()`, `addFunction()`, and `addContextFunction()` overwrite any existing registration with the same name. Pure and context-aware functions share a single namespace. +- `addContextFunction()` registers a function that receives a shallow-frozen snapshot of the evaluation context as its first argument. See [Context-aware functions](#context-aware-functions). +- `isContextFunction()` returns `true` if the named function was registered via `addContextFunction()`. +- `listTransforms()` and `listFunctions()` return the currently registered names. `listFunctions()` includes both pure and context-aware functions. - `clearCache()` clears the internal AST cache and compiled-expression cache. It does not remove registered transforms/functions. +- Pass a context type generic to `bonsai()` for end-to-end type safety: `evaluate`, `evaluateSync`, `addContextFunction`, `compile`, and `use` all propagate the type. If `MyContext` has required fields, TypeScript requires a context argument when evaluating. ## Extending the Runtime diff --git a/benchmarks/context-functions.bench.ts b/benchmarks/context-functions.bench.ts new file mode 100644 index 0000000..c530433 --- /dev/null +++ b/benchmarks/context-functions.bench.ts @@ -0,0 +1,60 @@ +import { describe, bench } from 'vitest' +import { bonsai } from '../src/index.js' + +/** + * Validate that: + * 1. Pure function calls are unaffected by the context-functions feature + * (we want zero regression on the hot path for users who do not adopt it). + * 2. Context function calls have predictable cost (one shallow-frozen copy + * per top-level evaluation, amortised across multiple calls). + * 3. Lazy frozen-context caching genuinely avoids per-call freeze cost. + */ + +const pureExpr = bonsai() +pureExpr.addFunction('id', (x) => x) + +const ctxExpr = bonsai<{ value: number }>() +ctxExpr.addContextFunction('current', (ctx) => ctx.value) + +const ctxExprMany = bonsai<{ value: number }>() +ctxExprMany.addContextFunction('current', (ctx) => ctx.value) + +const compiledPure = pureExpr.compile('id(1)') +const compiledCtx = ctxExpr.compile('current()') +// Six calls in one expression. With lazy-cached freeze this should pay the +// freeze cost once, then five "free" calls. +const compiledCtxMany = ctxExprMany.compile('current() + current() + current() + current() + current() + current()') + +describe('context-functions: zero-regression on pure path', () => { + bench('pure function call: id(1)', () => { + compiledPure.evaluateSync({}) + }) +}) + +describe('context-functions: single call', () => { + bench('context function call: current()', () => { + compiledCtx.evaluateSync({ value: 42 }) + }) +}) + +describe('context-functions: amortised freeze cost', () => { + bench('six context calls in one expression (amortised freeze)', () => { + compiledCtxMany.evaluateSync({ value: 42 }) + }) +}) + +describe('context-functions: context realism', () => { + const richExpr = bonsai<{ userId: string; perms: readonly string[]; tenantId: string; trace: { reqId: string } }>() + richExpr.addContextFunction('hasPermission', (ctx, action) => + ctx.perms.includes(action as string)) + const compiledRich = richExpr.compile('hasPermission("write")') + + bench('hasPermission("write") with 4-field context', () => { + compiledRich.evaluateSync({ + userId: 'u_1', + perms: ['read', 'write'], + tenantId: 't_1', + trace: { reqId: 'r_1' }, + }) + }) +}) diff --git a/benchmarks/overhead.bench.ts b/benchmarks/overhead.bench.ts index c1fb3a1..c7c2436 100644 --- a/benchmarks/overhead.bench.ts +++ b/benchmarks/overhead.bench.ts @@ -24,7 +24,7 @@ describe('overhead: literal 42', () => { compiled.evaluateSync({}) }) bench('raw evaluate (no cache lookup)', () => { - evaluate(ast, {}, transforms, functions, new ExecutionContext(policy)) + evaluate(ast, {}, { transforms, functions, contextFunctions: {} }, new ExecutionContext(policy)) }) bench('baseline: just return 42', () => { const node = ast as { value: number } @@ -41,7 +41,7 @@ describe('overhead: property access user.name', () => { compiled.evaluateSync(context) }) bench('raw evaluate (no cache lookup)', () => { - evaluate(ast, context, {}, {}, new ExecutionContext(policy)) + evaluate(ast, context, { transforms: {}, functions: {}, contextFunctions: {} }, new ExecutionContext(policy)) }) }) @@ -54,6 +54,6 @@ describe('overhead: comparison user.age >= 18 && user.verified', () => { compiled.evaluateSync(context) }) bench('raw evaluate (no cache lookup)', () => { - evaluate(ast, context, {}, {}, new ExecutionContext(policy)) + evaluate(ast, context, { transforms: {}, functions: {}, contextFunctions: {} }, new ExecutionContext(policy)) }) }) diff --git a/src/eval-ops.ts b/src/eval-ops.ts index ec7b4b0..fb87187 100644 --- a/src/eval-ops.ts +++ b/src/eval-ops.ts @@ -3,6 +3,7 @@ import type { ExecutionContext } from './execution-context.js' import type { ASTNode, BinaryExpressionOperator, + ContextFunctionFn, TransformFn, FunctionFn, UnaryOperator, @@ -121,6 +122,34 @@ export function resolveFunction(name: string, functions: Record, + contextFunctions: Record, +): ResolvedCallable { + if (Object.hasOwn(contextFunctions, name)) { + return { kind: 'context', fn: contextFunctions[name] } + } + if (Object.hasOwn(functions, name)) { + return { kind: 'pure', fn: functions[name] } + } + const suggestion = suggest(name, [...Object.keys(functions), ...Object.keys(contextFunctions)]) + throw new BonsaiReferenceError('function', name, suggestion) +} + export function getIdentifierName(node: ASTNode, message = 'Expected identifier'): string { if (node.type !== 'Identifier') { throw new Error(message) diff --git a/src/evaluator-async.ts b/src/evaluator-async.ts index 9e19dae..ebdee7a 100644 --- a/src/evaluator-async.ts +++ b/src/evaluator-async.ts @@ -1,7 +1,8 @@ -import type { ASTNode, FunctionFn, ObjectProperty, TransformFn } from './types.js' +import type { ASTNode, ObjectProperty } from './types.js' +import type { Bindings } from './plugins.js' import type { ExecutionContext } from './execution-context.js' import { attachLocation } from './errors.js' -import { type EvalEnv } from './evaluator.js' +import { type EvalEnv, getFrozenContext } from './evaluator.js' import { accessMember, applyBinaryOp, @@ -9,7 +10,7 @@ import { expandSpreadValue, getIdentifierName, getObjectLiteralKeyName, - resolveFunction, + resolveCallable, resolveTransform, validateMethodArgs, validateMethodCall, @@ -20,12 +21,18 @@ type AsyncEvalEnv = EvalEnv export async function evaluateAsync( node: ASTNode, context: Record, - transforms: Record, - functions: Record, + bindings: Bindings, guard: ExecutionContext, source?: string, ): Promise { - const env: AsyncEvalEnv = { ctx: context, tr: transforms, fn: functions, g: guard, s: source } + const env: AsyncEvalEnv = { + ctx: context, + tr: bindings.transforms, + fn: bindings.functions, + cfn: bindings.contextFunctions, + g: guard, + s: source, + } const result = await evalNodeAsync(node, env) guard.checkTimeout() return result @@ -214,12 +221,14 @@ async function evalCallExpressionAsync( if (node.callee.type === 'Identifier') { try { - const func = resolveFunction(node.callee.name, fn) + const resolved = resolveCallable(node.callee.name, fn, env.cfn) const args: unknown[] = [] for (const arg of node.args) { await pushCallArgumentAsync(args, arg, env) } - const result = await func(...args) + const result = resolved.kind === 'context' + ? await resolved.fn(getFrozenContext(env), ...args) + : await resolved.fn(...args) g.checkTimeout() return result } catch (e) { diff --git a/src/evaluator.ts b/src/evaluator.ts index 1df38f1..0ad8909 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,4 +1,5 @@ -import type { ASTNode, FunctionFn, ObjectProperty, TransformFn } from './types.js' +import type { ASTNode, ContextFunctionFn, FunctionFn, ObjectProperty, TransformFn } from './types.js' +import type { Bindings } from './plugins.js' import type { ExecutionContext } from './execution-context.js' import { attachLocation, BonsaiTypeError } from './errors.js' import { @@ -8,7 +9,7 @@ import { expandSpreadValue, getIdentifierName, getObjectLiteralKeyName, - resolveFunction, + resolveCallable, resolveTransform, validateMethodArgs, validateMethodCall, @@ -29,19 +30,42 @@ export interface EvalEnv { ctx: Record tr: Record fn: Record + cfn: Record g: ExecutionContext s?: string + /** + * Lazily materialised shallow-frozen copy of `ctx`, shared across all + * context-function invocations within a single evaluation. Computed on + * the first context-function call. Undefined means no context function + * has been called yet in this evaluation. + */ + frozenCtx?: Readonly> +} + +/** Return (and lazily create) the frozen context snapshot for context functions. */ +export function getFrozenContext(env: EvalEnv): Readonly> { + if (!env.frozenCtx) { + // Shallow copy so we never freeze the user's own context object. + env.frozenCtx = Object.freeze({ ...env.ctx }) + } + return env.frozenCtx } export function evaluate( node: ASTNode, context: Record, - transforms: Record, - functions: Record, + bindings: Bindings, guard: ExecutionContext, source?: string, ): unknown { - return evalNode(node, { ctx: context, tr: transforms, fn: functions, g: guard, s: source }) + return evalNode(node, { + ctx: context, + tr: bindings.transforms, + fn: bindings.functions, + cfn: bindings.contextFunctions, + g: guard, + s: source, + }) } function evalNode(node: ASTNode, env: EvalEnv): unknown { @@ -207,12 +231,15 @@ function evalCallExpression(node: Extract, if (node.callee.type === 'Identifier') { try { - const func = resolveFunction(node.callee.name, fn) + const resolved = resolveCallable(node.callee.name, fn, env.cfn) const args: unknown[] = [] for (const arg of node.args) { pushCallArgument(args, arg, env) } - return rejectPromise(func(...args), 'function', node.callee.name) + const result = resolved.kind === 'context' + ? resolved.fn(getFrozenContext(env), ...args) + : resolved.fn(...args) + return rejectPromise(result, 'function', node.callee.name) } catch (e) { if (s) attachLocation(e, s, node.start, node.end) throw e diff --git a/src/index.ts b/src/index.ts index c219238..caf79b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,14 @@ import type { BonsaiOptions, + BonsaiContext, BonsaiInstance, CompiledExpression, + EvaluationContextArgs, ValidationResult, ExpressionReferences, TransformFn, FunctionFn, + ContextFunctionFn, BonsaiPlugin, ASTNode, } from './types.js' @@ -38,6 +41,7 @@ export type { BonsaiOptions, TransformFn, FunctionFn, + ContextFunctionFn, } from './types.js' const DEFAULT_CACHE_SIZE = 256 @@ -56,18 +60,32 @@ export function evaluateExpression(expression: string, context?: Re /** * Create a new Bonsai instance with optional safety and caching configuration. - * Register transforms and functions via `.use()`, `.addTransform()`, and `.addFunction()`. + * Register transforms and functions via `.use()`, `.addTransform()`, `.addFunction()`, + * and `.addContextFunction()`. + * + * Pass a context type argument to get end-to-end type safety on the evaluation + * context: `bonsai()` will type-check `evaluate(expr, ctx)` calls, + * require a context argument when `MyContext` has required fields, and + * propagate `MyContext` into context-aware function signatures. * * @example * ```ts * const expr = bonsai({ timeout: 50 }) * expr.use(strings) * expr.evaluateSync('name |> trim |> upper', { name: ' hello ' }) // "HELLO" + * + * type AppCtx = { userId: string; perms: string[] } + * const app = bonsai() + * app.addContextFunction('hasPermission', (ctx, action) => + * ctx.perms.includes(String(action))) + * app.evaluateSync('hasPermission("write")', { userId: 'u_1', perms: ['write'] }) * ``` */ -export function bonsai(options: BonsaiOptions = {}): BonsaiInstance { +export function bonsai>( + options: BonsaiOptions = {}, +): BonsaiInstance { const registry = createPluginRegistry() - const cache = new LRUCache(options.cacheSize ?? DEFAULT_CACHE_SIZE) + const cache = new LRUCache>(options.cacheSize ?? DEFAULT_CACHE_SIZE) const astCache = new LRUCache(options.cacheSize ?? DEFAULT_CACHE_SIZE) const policy = new SecurityPolicy(options) @@ -88,26 +106,28 @@ export function bonsai(options: BonsaiOptions = {}): BonsaiInstance { return ast } - function compileExpr(source: string): CompiledExpression { + function compileExpr(source: string): CompiledExpression { const cached = cache.get(source) if (cached) return cached const optimized = getAst(source) - const compiled: CompiledExpression = { + const compiled: CompiledExpression = { ast: optimized, source, - async evaluate(context = {}) { - return evaluateAsync(optimized, context, registry.transforms, registry.functions, createExecutionContext(), source) as Promise + async evaluate(...args: EvaluationContextArgs) { + const ctx = (args[0] ?? {}) as Record + return evaluateAsync(optimized, ctx, registry.bindings, createExecutionContext(), source) as Promise }, - evaluateSync(context = {}) { + evaluateSync(...args: EvaluationContextArgs) { + const ctx = (args[0] ?? {}) as Record if (syncCtxInUse) { - return evaluate(optimized, context, registry.transforms, registry.functions, createExecutionContext(), source) as T + return evaluate(optimized, ctx, registry.bindings, createExecutionContext(), source) as T } syncCtxInUse = true try { syncCtx.reset() - return evaluate(optimized, context, registry.transforms, registry.functions, syncCtx, source) as T + return evaluate(optimized, ctx, registry.bindings, syncCtx, source) as T } finally { syncCtxInUse = false } @@ -118,14 +138,30 @@ export function bonsai(options: BonsaiOptions = {}): BonsaiInstance { return compiled } - const instance: BonsaiInstance = { - use(plugin) { plugin(instance); return instance }, + const instance: BonsaiInstance = { + use( + plugin: TCtx extends TPluginCtx ? BonsaiPlugin : never, + ) { + // `use()` only accepts plugins whose required context is satisfied by + // this instance's context type. The cast bridges that conditional + // relationship for the implementation. + plugin(instance as unknown as BonsaiInstance) + return instance + }, addTransform(name, fn) { registry.addTransform(name, fn); return instance }, addFunction(name, fn) { registry.addFunction(name, fn); return instance }, + addContextFunction(name, fn) { + // Cast: internal registry stores context functions as ContextFunctionFn + // (default generic), public API exposes them typed against TCtx. The + // runtime treats the ctx arg as opaque, so the cast is sound. + registry.addContextFunction(name, fn as unknown as ContextFunctionFn) + return instance + }, removeTransform(name) { return registry.removeTransform(name) }, removeFunction(name) { return registry.removeFunction(name) }, hasTransform(name) { return registry.getTransform(name) !== undefined }, - hasFunction(name) { return registry.getFunction(name) !== undefined }, + hasFunction(name) { return registry.hasFunction(name) }, + isContextFunction(name) { return registry.isContextFunction(name) }, listTransforms() { return registry.getTransformNames() }, listFunctions() { return registry.getFunctionNames() }, getPolicy() { @@ -136,21 +172,23 @@ export function bonsai(options: BonsaiOptions = {}): BonsaiInstance { }, clearCache() { cache.clear(); astCache.clear() }, compile(expression) { return compileExpr(expression) }, - async evaluate(expression: string, context = {}) { + async evaluate(expression: string, ...args: EvaluationContextArgs) { const ast = getAst(expression) - return evaluateAsync(ast, context, registry.transforms, registry.functions, createExecutionContext(), expression) as Promise + const ctx = (args[0] ?? {}) as Record + return evaluateAsync(ast, ctx, registry.bindings, createExecutionContext(), expression) as Promise }, - evaluateSync(expression: string, context = {}) { + evaluateSync(expression: string, ...args: EvaluationContextArgs) { // Hot path: reuse pooled ExecutionContext to avoid per-call allocation const ast = getAst(expression) + const ctx = (args[0] ?? {}) as Record if (syncCtxInUse) { - // Reentrant call (e.g., custom function calling evaluateSync) — fresh allocation - return evaluate(ast, context, registry.transforms, registry.functions, createExecutionContext(), expression) as T + // Reentrant call (e.g. custom function calling evaluateSync). Allocate fresh. + return evaluate(ast, ctx, registry.bindings, createExecutionContext(), expression) as T } syncCtxInUse = true try { syncCtx.reset() - return evaluate(ast, context, registry.transforms, registry.functions, syncCtx, expression) as T + return evaluate(ast, ctx, registry.bindings, syncCtx, expression) as T } finally { syncCtxInUse = false } diff --git a/src/plugins.ts b/src/plugins.ts index bbf22d3..f810ad0 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,38 +1,90 @@ -import type { TransformFn, FunctionFn } from './types.js' +import type { TransformFn, FunctionFn, ContextFunctionFn } from './types.js' + +/** + * Snapshot of every registered binding passed to the evaluator on each call. + * Bundled into a single object so the evaluator signature stays compact and + * we avoid per-call allocation (the registry caches and reuses the snapshot + * until a registration changes). + */ +export interface Bindings { + readonly transforms: Record + readonly functions: Record + readonly contextFunctions: Record +} export interface PluginRegistry { addTransform(name: string, fn: TransformFn): void addFunction(name: string, fn: FunctionFn): void + addContextFunction(name: string, fn: ContextFunctionFn): void removeTransform(name: string): boolean removeFunction(name: string): boolean getTransform(name: string): TransformFn | undefined getFunction(name: string): FunctionFn | undefined + getContextFunction(name: string): ContextFunctionFn | undefined + isContextFunction(name: string): boolean + hasFunction(name: string): boolean getTransformNames(): string[] getFunctionNames(): string[] use(plugin: (registry: PluginRegistry) => void): void readonly transforms: Record readonly functions: Record + readonly contextFunctions: Record + /** Cached snapshot of all bindings. Rebuilt only when a registration changes. */ + readonly bindings: Bindings } export function createPluginRegistry(): PluginRegistry { const transformMap = new Map() const functionMap = new Map() + const contextFunctionMap = new Map() - // Cached snapshots — rebuilt only when registry changes + // Cached snapshots, rebuilt only when registry changes let transformsCache: Record = {} let functionsCache: Record = {} + let contextFunctionsCache: Record = {} let transformsDirty = false let functionsDirty = false + let contextFunctionsDirty = false const registry: PluginRegistry = { - addTransform(name, fn) { transformMap.set(name, fn); transformsDirty = true }, - addFunction(name, fn) { functionMap.set(name, fn); functionsDirty = true }, - removeTransform(name) { const r = transformMap.delete(name); if (r) transformsDirty = true; return r }, - removeFunction(name) { const r = functionMap.delete(name); if (r) functionsDirty = true; return r }, + addTransform(name, fn) { + transformMap.set(name, fn) + transformsDirty = true + }, + addFunction(name, fn) { + functionMap.set(name, fn) + functionsDirty = true + // Context and pure functions share a namespace: overwrite the other kind. + if (contextFunctionMap.delete(name)) contextFunctionsDirty = true + }, + addContextFunction(name, fn) { + contextFunctionMap.set(name, fn) + contextFunctionsDirty = true + if (functionMap.delete(name)) functionsDirty = true + }, + removeTransform(name) { + const r = transformMap.delete(name) + if (r) transformsDirty = true + return r + }, + removeFunction(name) { + const pure = functionMap.delete(name) + const ctx = contextFunctionMap.delete(name) + if (pure) functionsDirty = true + if (ctx) contextFunctionsDirty = true + return pure || ctx + }, getTransform(name) { return transformMap.get(name) }, getFunction(name) { return functionMap.get(name) }, + getContextFunction(name) { return contextFunctionMap.get(name) }, + isContextFunction(name) { return contextFunctionMap.has(name) }, + hasFunction(name) { return functionMap.has(name) || contextFunctionMap.has(name) }, getTransformNames() { return [...transformMap.keys()] }, - getFunctionNames() { return [...functionMap.keys()] }, + getFunctionNames() { + const names = new Set(functionMap.keys()) + for (const name of contextFunctionMap.keys()) names.add(name) + return [...names] + }, use(plugin) { plugin(registry) }, get transforms() { if (transformsDirty) { @@ -48,7 +100,32 @@ export function createPluginRegistry(): PluginRegistry { } return functionsCache }, + get contextFunctions() { + if (contextFunctionsDirty) { + contextFunctionsCache = Object.fromEntries(contextFunctionMap) + contextFunctionsDirty = false + } + return contextFunctionsCache + }, + get bindings(): Bindings { + // Reuse the cached individual snapshots. The composite object is + // rebuilt only when any of the three underlying caches change. + const t = registry.transforms + const f = registry.functions + const cf = registry.contextFunctions + if ( + !bindingsCache + || bindingsCache.transforms !== t + || bindingsCache.functions !== f + || bindingsCache.contextFunctions !== cf + ) { + bindingsCache = { transforms: t, functions: f, contextFunctions: cf } + } + return bindingsCache + }, } + let bindingsCache: Bindings | undefined + return registry } diff --git a/src/types.ts b/src/types.ts index d24f216..a4b1dd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -235,49 +235,81 @@ export type TransformFn = (value: unknown, ...args: unknown[]) => unknown | Prom /** A function is called directly by name: `myFunction(arg1, arg2)`. */ export type FunctionFn = (...args: unknown[]) => unknown | Promise -/** A plugin receives the Bonsai instance and extends it with transforms or functions. */ -export type BonsaiPlugin = (instance: BonsaiInstance) => void +/** The shape of an evaluation context object. */ +export type BonsaiContext = object +type EmptyContext = Record + +/** + * Evaluation/compiled call sites only require a `context` argument when the + * instance context type has required keys. Untyped instances keep the current + * ergonomic `evaluateSync(expr)` / `compiled.evaluateSync()` behavior. + */ +export type EvaluationContextArgs> = + EmptyContext extends TCtx ? [context?: TCtx] : [context: TCtx] + +/** + * A context-aware function. Receives a shallow-frozen copy of the evaluation + * context as its first parameter, followed by the call's argument values. + * Registered via {@link BonsaiInstance.addContextFunction}. + */ +export type ContextFunctionFn> = + (context: Readonly, ...args: unknown[]) => unknown | Promise + +/** A plugin receives a Bonsai instance and extends it with transforms or functions. */ +export type BonsaiPlugin> = + (instance: BonsaiInstance) => void /** Core Bonsai instance returned by `bonsai()`. */ -export interface BonsaiInstance { +export interface BonsaiInstance> { /** Register a plugin that extends this instance with transforms/functions. */ - use(plugin: BonsaiPlugin): this + use( + plugin: TCtx extends TPluginCtx ? BonsaiPlugin : never, + ): this /** Register a named transform for use with the pipe operator (`|>`). */ addTransform(name: string, fn: TransformFn): this /** Register a named function callable as `name(args)` in expressions. */ addFunction(name: string, fn: FunctionFn): this + /** + * Register a context-aware function callable as `name(args)` in expressions. + * The function receives a frozen snapshot of the evaluation context as its + * first parameter. Shares a namespace with {@link addFunction}: registering + * the same name with either method overwrites the previous registration. + */ + addContextFunction(name: string, fn: ContextFunctionFn): this /** Remove a previously registered transform. Returns true if it existed. */ removeTransform(name: string): boolean - /** Remove a previously registered function. Returns true if it existed. */ + /** Remove a previously registered function (pure or context-aware). Returns true if it existed. */ removeFunction(name: string): boolean /** Check whether a transform with the given name is registered. */ hasTransform(name: string): boolean - /** Check whether a function with the given name is registered. */ + /** Check whether a function (pure or context-aware) with the given name is registered. */ hasFunction(name: string): boolean + /** Check whether a function with the given name was registered via {@link addContextFunction}. */ + isContextFunction(name: string): boolean /** List all registered transform names. */ listTransforms(): string[] - /** List all registered function names. */ + /** List all registered function names (both pure and context-aware). */ listFunctions(): string[] /** Returns a read-only snapshot of the security policy for autocomplete filtering. */ getPolicy(): PolicySnapshot /** Clear the compiled expression and AST caches. */ clearCache(): void /** Pre-compile an expression for repeated evaluation. */ - compile(expression: string): CompiledExpression + compile(expression: string): CompiledExpression /** Evaluate an expression asynchronously. Required when transforms/functions are async. */ - evaluate(expression: string, context?: Record): Promise + evaluate(expression: string, ...args: EvaluationContextArgs): Promise /** Evaluate an expression synchronously. Throws if a transform/function returns a Promise. */ - evaluateSync(expression: string, context?: Record): T + evaluateSync(expression: string, ...args: EvaluationContextArgs): T /** Check if an expression is syntactically valid without evaluating it. */ validate(expression: string): ValidationResult } /** A pre-compiled expression that can be evaluated repeatedly with different contexts. */ -export interface CompiledExpression { +export interface CompiledExpression> { /** Evaluate asynchronously. Required when transforms/functions are async. */ - evaluate(context?: Record): Promise + evaluate(...args: EvaluationContextArgs): Promise /** Evaluate synchronously. Throws if a transform/function returns a Promise. */ - evaluateSync(context?: Record): T + evaluateSync(...args: EvaluationContextArgs): T /** The optimized AST after constant folding and dead branch elimination. */ readonly ast: ASTNode /** The original expression string. */ diff --git a/tests/context-functions-types.test.ts b/tests/context-functions-types.test.ts new file mode 100644 index 0000000..1d26f4a --- /dev/null +++ b/tests/context-functions-types.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest' +import { + bonsai, + type BonsaiInstance, + type BonsaiPlugin, + type CompiledExpression, + type ContextFunctionFn, +} from '../src/index.js' + +/** + * Compile-time type assertions. These are validated by `tsc` during the + * typecheck step rather than by runtime assertions. They lock the public + * API generics in place against accidental regressions. + */ +describe('addContextFunction / bonsai() type surface', () => { + it('infers ctx type from the instance generic', () => { + interface Ctx { userId: string; perms: readonly string[] } + const app = bonsai() + app.addContextFunction('whoami', (ctx) => { + // ctx is Readonly at compile time + const id: string = ctx.userId + const perms: readonly string[] = ctx.perms + return `${id}:${perms.length}` + }) + expect(app.evaluateSync('whoami()', { userId: 'u_1', perms: ['read'] })).toBe('u_1:1') + }) + + it('accepts a function with a narrower context type (parameter contravariance)', () => { + interface Ctx { userId: string; perms: readonly string[]; tenantId: string } + const app = bonsai() + // Only declare the fields we care about. TS still accepts this because + // every Ctx is assignable to { userId: string }. + app.addContextFunction('whoami', (ctx) => ctx.userId) + expect(app.evaluateSync('whoami()', { userId: 'u_1', perms: [], tenantId: 't' })).toBe('u_1') + }) + + it('rejects wrong-shape context at compile time (verified via ts-expect-error)', () => { + interface Ctx { userId: string } + const app = bonsai() + app.addContextFunction('whoami', (ctx) => ctx.userId) + + // @ts-expect-error typed instances with required context fields require the context argument + app.evaluateSync('whoami()') + + // @ts-expect-error context is missing the required userId field + app.evaluateSync('whoami()', { wrongField: 'value' }) + + // @ts-expect-error reading an unknown context field is rejected + app.addContextFunction('bad', (ctx) => ctx.nonexistentField) + + // Sanity: the correct call works at runtime. + expect(app.evaluateSync('whoami()', { userId: 'u_1' })).toBe('u_1') + }) + + it('default generic (untyped instance) accepts any Record context', () => { + const expr = bonsai() + expr.addContextFunction('grab', (ctx) => ctx.anything) + expect(expr.evaluateSync('grab()', { anything: 42 })).toBe(42) + expect(expr.evaluateSync('grab()', { somethingElse: true })).toBe(undefined) + }) + + it('keeps the context argument optional when the context type has no required fields', () => { + interface OptionalCtx { userId?: string } + const expr = bonsai() + expr.addContextFunction('whoami', (ctx) => ctx.userId) + expect(expr.evaluateSync('whoami()')).toBe(undefined) + expect(expr.evaluateSync('whoami()', { userId: 'u_1' })).toBe('u_1') + + const compiled = expr.compile('whoami()') + expect(compiled.evaluateSync()).toBe(undefined) + expect(compiled.evaluateSync({ userId: 'u_2' })).toBe('u_2') + }) + + it('BonsaiInstance is generic over context', () => { + interface Ctx { a: number } + const _typed: BonsaiInstance = bonsai() + const _untyped: BonsaiInstance = bonsai() + expect(typeof _typed.addContextFunction).toBe('function') + expect(typeof _untyped.addContextFunction).toBe('function') + }) + + it('CompiledExpression is generic over context', () => { + interface Ctx { x: number } + const app = bonsai() + const compiled: CompiledExpression = app.compile('x') + expect(compiled.evaluateSync({ x: 5 })).toBe(5) + + // @ts-expect-error typed compiled expressions with required context fields require the context argument + compiled.evaluateSync() + + // @ts-expect-error wrong context shape on compiled expression + compiled.evaluateSync({ wrongField: 'value' }) + }) + + it('BonsaiPlugin is generic over context', () => { + interface Ctx { tenantId: string } + const plugin: BonsaiPlugin = (e) => { + e.addContextFunction('tenant', (ctx) => ctx.tenantId) + } + const app = bonsai().use(plugin) + expect(app.evaluateSync('tenant()', { tenantId: 't_1' })).toBe('t_1') + }) + + it('plugins typed against a narrower context can be used with a wider instance context', () => { + interface PluginCtx { tenantId: string } + interface AppCtx extends PluginCtx { userId: string } + const plugin: BonsaiPlugin = (e) => { + e.addContextFunction('tenant', (ctx) => ctx.tenantId) + } + const app = bonsai().use(plugin) + expect(app.evaluateSync('tenant()', { tenantId: 't_1', userId: 'u_1' })).toBe('t_1') + + // @ts-expect-error plugin requires tenantId, but this instance context does not provide it + bonsai<{ userId: string }>().use(plugin) + }) + + it('untyped plugins work on typed instances without casts', () => { + const plugin: BonsaiPlugin = (e) => { + e.addFunction('two', () => 2) + } + const app = bonsai<{ x: number }>().use(plugin) + expect(app.evaluateSync('two()', { x: 1 })).toBe(2) + }) + + it('ContextFunctionFn is exported and useful for explicit typing', () => { + interface Ctx { value: number } + const fn: ContextFunctionFn = (ctx, ...args) => (ctx.value * (args[0] as number)) + const app = bonsai() + app.addContextFunction('multiply', fn) + expect(app.evaluateSync('multiply(3)', { value: 7 })).toBe(21) + }) + + it('addFunction signature is unchanged (no TCtx leakage)', () => { + interface Ctx { foo: string } + const app = bonsai() + app.addFunction('pure', (...args) => args.length) + expect(app.evaluateSync('pure(1, 2, 3)', { foo: 'bar' })).toBe(3) + }) + + it('rejects non-object context generics', () => { + // @ts-expect-error evaluation context must be object-shaped + bonsai() + expect(true).toBe(true) + }) +}) diff --git a/tests/context-functions.test.ts b/tests/context-functions.test.ts new file mode 100644 index 0000000..a078500 --- /dev/null +++ b/tests/context-functions.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect } from 'vitest' +import { bonsai, BonsaiReferenceError, type BonsaiPlugin } from '../src/index.js' + +describe('addContextFunction', () => { + describe('basic invocation', () => { + it('receives the supplied context as its first argument', () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('whoami', (ctx) => ctx.userId) + expect(expr.evaluateSync('whoami()', { userId: 'u_1' })).toBe('u_1') + }) + + it('receives call arguments after the context', () => { + const expr = bonsai<{ name: string }>() + expr.addContextFunction('greet', (ctx, salutation) => + `${String(salutation)}, ${ctx.name}`) + expect(expr.evaluateSync('greet("Hello")', { name: 'Dan' })).toBe('Hello, Dan') + }) + + it('works with multiple call arguments', () => { + const expr = bonsai<{ value: string }>() + expr.addContextFunction('format', (ctx, prefix, suffix) => + `${String(prefix)}${ctx.value}${String(suffix)}`) + expect(expr.evaluateSync('format("<", ">")', { value: 'x' })).toBe('') + }) + + it('returns an empty frozen object when no context is provided', () => { + const expr = bonsai() + let captured: unknown + expr.addContextFunction('grab', (ctx) => { captured = ctx; return null }) + expr.evaluateSync('grab()') + expect(captured).toEqual({}) + expect(Object.isFrozen(captured)).toBe(true) + }) + }) + + describe('isolation', () => { + it('passes a frozen context to the function', () => { + const expr = bonsai<{ a: number; b: string }>() + let captured: Readonly<{ a: number; b: string }> | undefined + expr.addContextFunction('grab', (ctx) => { captured = ctx; return null }) + expr.evaluateSync('grab()', { a: 1, b: 'two' }) + expect(Object.isFrozen(captured)).toBe(true) + }) + + it('does not mutate the original context object when the function tries to write', () => { + const expr = bonsai<{ name: string }>() + expr.addContextFunction('mutate', (ctx) => { + // Attempt to mutate the frozen copy. Throws in strict mode (ESM modules + // are always strict), so swallow to assert isolation regardless. + try { (ctx as { name: string; injected?: string }).injected = 'bad' } catch { /* expected */ } + return null + }) + const original = { name: 'Dan' } + expr.evaluateSync('mutate()', original) + expect(original).toEqual({ name: 'Dan' }) + expect(Object.isFrozen(original)).toBe(false) + }) + + it('shares one frozen snapshot across multiple context-function calls in one evaluation', () => { + const expr = bonsai<{ x: number }>() + const captured: unknown[] = [] + expr.addContextFunction('snap', (ctx) => { captured.push(ctx); return ctx.x }) + expr.evaluateSync('snap() + snap() + snap()', { x: 1 }) + expect(captured.length).toBe(3) + expect(captured[0]).toBe(captured[1]) + expect(captured[1]).toBe(captured[2]) + }) + + it('creates a fresh snapshot per top-level evaluation', () => { + const expr = bonsai<{ x: number }>() + const captured: unknown[] = [] + expr.addContextFunction('snap', (ctx) => { captured.push(ctx); return ctx.x }) + expr.evaluateSync('snap()', { x: 1 }) + expr.evaluateSync('snap()', { x: 2 }) + expect(captured[0]).not.toBe(captured[1]) + }) + + it('does not pass context to pure functions registered via addFunction', () => { + const expr = bonsai<{ extra: string }>() + expr.addFunction('pure', (...args) => args.length) + expr.addContextFunction('contextual', (_ctx, ...args) => args.length) + expect(expr.evaluateSync('pure(1, 2)', { extra: 'ignored' })).toBe(2) + expect(expr.evaluateSync('contextual(1, 2)', { extra: 'ignored' })).toBe(2) + }) + }) + + describe('async invocation', () => { + it('works with async context functions via evaluate', async () => { + const expr = bonsai<{ tier: 'pro' | 'free' }>() + expr.addContextFunction('asyncLookup', async (ctx) => { + await new Promise(resolve => { setTimeout(resolve, 1) }) + return ctx.tier + }) + const result = await expr.evaluate('asyncLookup() == "pro"', { tier: 'pro' }) + expect(result).toBe(true) + }) + + it('rejects async context functions in evaluateSync with a helpful error', () => { + const expr = bonsai<{ value: number }>() + expr.addContextFunction('lookup', async (ctx) => ctx.value) + expect(() => expr.evaluateSync('lookup()', { value: 1 })).toThrow(/lookup/) + }) + + it('supports parallel-safe context across async functions', async () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('slow', async (ctx) => { + await new Promise(r => { setTimeout(r, 5) }) + return ctx.userId + }) + + const results = await Promise.all([ + expr.evaluate('slow()', { userId: 'u_1' }), + expr.evaluate('slow()', { userId: 'u_2' }), + expr.evaluate('slow()', { userId: 'u_3' }), + ]) + expect(results).toEqual(['u_1', 'u_2', 'u_3']) + }) + }) + + describe('compiled expressions', () => { + it('preserves context-function behavior across compile + evaluateSync', () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('whoami', (ctx) => ctx.userId) + const compiled = expr.compile('whoami()') + expect(compiled.evaluateSync({ userId: 'u_1' })).toBe('u_1') + expect(compiled.evaluateSync({ userId: 'u_2' })).toBe('u_2') + }) + + it('preserves context-function behavior across compile + async evaluate', async () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('whoami', async (ctx) => ctx.userId) + const compiled = expr.compile('whoami()') + expect(await compiled.evaluate({ userId: 'u_1' })).toBe('u_1') + expect(await compiled.evaluate({ userId: 'u_2' })).toBe('u_2') + }) + }) + + describe('namespace and introspection', () => { + it('addContextFunction(name) overwrites a prior addFunction(name)', () => { + const expr = bonsai<{ value: string }>() + expr.addFunction('x', () => 'pure') + expr.addContextFunction('x', (ctx) => `ctx:${ctx.value}`) + expect(expr.evaluateSync('x()', { value: 'hi' })).toBe('ctx:hi') + expect(expr.isContextFunction('x')).toBe(true) + }) + + it('addFunction(name) overwrites a prior addContextFunction(name)', () => { + const expr = bonsai<{ value: string }>() + expr.addContextFunction('x', (ctx) => `ctx:${ctx.value}`) + expr.addFunction('x', () => 'pure') + expect(expr.evaluateSync('x()', { value: 'hi' })).toBe('pure') + expect(expr.isContextFunction('x')).toBe(false) + }) + + it('hasFunction returns true for context-registered names', () => { + const expr = bonsai<{ value: number }>() + expr.addContextFunction('x', (ctx) => ctx.value) + expect(expr.hasFunction('x')).toBe(true) + }) + + it('removeFunction removes context-registered names', () => { + const expr = bonsai<{ value: number }>() + expr.addContextFunction('x', (ctx) => ctx.value) + expect(expr.removeFunction('x')).toBe(true) + expect(expr.hasFunction('x')).toBe(false) + expect(expr.isContextFunction('x')).toBe(false) + }) + + it('listFunctions includes both pure and context-aware names without duplicates', () => { + const expr = bonsai<{ x: number }>() + expr.addFunction('a', () => 1) + expr.addContextFunction('b', (ctx) => ctx.x) + expr.addContextFunction('a', (ctx) => ctx.x) + const names = expr.listFunctions().sort() + expect(names).toEqual(['a', 'b']) + }) + + it('isContextFunction returns false for pure functions', () => { + const expr = bonsai() + expr.addFunction('pure', () => 1) + expect(expr.isContextFunction('pure')).toBe(false) + }) + + it('isContextFunction returns false for unknown names', () => { + const expr = bonsai() + expect(expr.isContextFunction('missing')).toBe(false) + }) + }) + + describe('reference resolution', () => { + it('throws a helpful BonsaiReferenceError for unknown function names', () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('lookupUser', (ctx) => ctx.userId) + expect(() => expr.evaluateSync('lookupUsr()', { userId: 'u_1' })).toThrow(BonsaiReferenceError) + }) + + it('suggests context-registered names in reference errors', () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('lookupUser', (ctx) => ctx.userId) + try { + expr.evaluateSync('lookpUser()', { userId: 'u_1' }) + throw new Error('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(BonsaiReferenceError) + expect((err as Error).message).toMatch(/lookupUser/) + } + }) + + it('suggests across both pure and context registries when name is unknown', () => { + const expr = bonsai<{ x: number }>() + expr.addFunction('purefn', () => 0) + expr.addContextFunction('ctxfn', (ctx) => ctx.x) + try { + expr.evaluateSync('ctxfm()', { x: 1 }) + throw new Error('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(BonsaiReferenceError) + // The suggestion should reach into the context-function namespace too + expect((err as Error).message).toMatch(/ctxfn/) + } + }) + }) + + describe('error semantics', () => { + it('missing context fields resolve to undefined inside the function (no throw)', () => { + // Matches bonsai's general behavior: unsupplied identifiers are undefined, + // not errors. Stays consistent for context functions. + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('whoami', (ctx) => ctx.userId) + // Cast removes the readonly-shape requirement so we can simulate a call + // from JS where no context object is supplied at all. + const noCtx = expr as unknown as { evaluateSync: (s: string) => unknown } + expect(noCtx.evaluateSync('whoami()')).toBe(undefined) + }) + + it('errors thrown from the function propagate as-is', () => { + // Plain user errors propagate without modification. Bonsai only decorates + // its own typed errors (BonsaiTypeError, BonsaiSecurityError, + // BonsaiReferenceError) with source-location formatting. + const expr = bonsai<{ userId: string }>() + const sentinel = new Error('intentional') + expr.addContextFunction('boom', () => { throw sentinel }) + try { + expr.evaluateSync('1 + boom()', { userId: 'u_1' }) + throw new Error('should have thrown') + } catch (err) { + expect(err).toBe(sentinel) + } + }) + + it('errors thrown from async functions propagate via the returned promise', async () => { + const expr = bonsai<{ userId: string }>() + expr.addContextFunction('boom', async () => { throw new Error('async-boom') }) + await expect(expr.evaluate('boom()', { userId: 'u_1' })).rejects.toThrow(/async-boom/) + }) + + it('reports the function name when evaluateSync sees a promise from a context function', () => { + const expr = bonsai<{ tier: string }>() + expr.addContextFunction('asyncCtx', async (ctx) => ctx.tier) + expect(() => expr.evaluateSync('asyncCtx()', { tier: 'pro' })).toThrow(/asyncCtx/) + }) + }) + + describe('plugin integration', () => { + it('plugins can register context-aware functions', () => { + interface Ctx { tenantId: string } + const tenancy: BonsaiPlugin = (e) => { + e.addContextFunction('currentTenant', (ctx) => ctx.tenantId) + } + const app = bonsai() + app.use(tenancy) + expect(app.evaluateSync('currentTenant()', { tenantId: 't_42' })).toBe('t_42') + }) + + it('plugins typed against a narrower context work on wider instances', () => { + interface PluginCtx { tenantId: string } + interface AppCtx extends PluginCtx { userId: string } + const tenancy: BonsaiPlugin = (e) => { + e.addContextFunction('currentTenant', (ctx) => ctx.tenantId) + } + const app = bonsai() + app.use(tenancy) + expect(app.evaluateSync('currentTenant()', { tenantId: 't_42', userId: 'u_1' })).toBe('t_42') + }) + + it('untyped plugins continue to work on typed instances via default generic', () => { + const plugin: BonsaiPlugin = (e) => { + e.addFunction('two', () => 2) + } + const app = bonsai<{ x: number }>() + app.use(plugin) + expect(app.evaluateSync('two()', { x: 1 })).toBe(2) + }) + }) + + describe('integration with other features', () => { + it('composes with pipe transforms', () => { + const expr = bonsai<{ userName: string }>() + expr.addContextFunction('userName', (ctx) => ctx.userName) + expr.addTransform('upper', (v) => String(v).toUpperCase()) + expect(expr.evaluateSync('userName() |> upper', { userName: 'dan' })).toBe('DAN') + }) + + it('can be used inside conditional expressions', () => { + const expr = bonsai<{ tier: 'pro' | 'free' }>() + expr.addContextFunction('isPro', (ctx) => ctx.tier === 'pro') + expect(expr.evaluateSync('isPro() ? "yes" : "no"', { tier: 'pro' })).toBe('yes') + expect(expr.evaluateSync('isPro() ? "yes" : "no"', { tier: 'free' })).toBe('no') + }) + + it('can be used inside lambdas (array.filter with bonsai lambda syntax)', () => { + interface Ctx { minAge: number; users: readonly { age: number }[] } + const expr = bonsai() + expr.addContextFunction('threshold', (ctx) => ctx.minAge) + const result = expr.evaluateSync( + 'users.filter(.age >= threshold())', + { minAge: 18, users: [{ age: 16 }, { age: 21 }, { age: 19 }] }, + ) + expect(result).toEqual([{ age: 21 }, { age: 19 }]) + }) + }) +}) diff --git a/tests/evaluator-transforms.test.ts b/tests/evaluator-transforms.test.ts index 1655a75..0008e4b 100644 --- a/tests/evaluator-transforms.test.ts +++ b/tests/evaluator-transforms.test.ts @@ -13,7 +13,7 @@ function run( ) { const ast = parse(expr) const ec = new ExecutionContext(new SecurityPolicy()) - return evaluate(ast, context, transforms, functions, ec) + return evaluate(ast, context, { transforms, functions, contextFunctions: {} }, ec) } describe('evaluator - functions', () => { diff --git a/tests/evaluator.test.ts b/tests/evaluator.test.ts index 4082874..ccbbf57 100644 --- a/tests/evaluator.test.ts +++ b/tests/evaluator.test.ts @@ -7,7 +7,7 @@ import { bonsai, BonsaiTypeError } from '../src/index.js' function run(expr: string, context: Record = {}) { const ast = parse(expr) const ec = new ExecutionContext(new SecurityPolicy()) - return evaluate(ast, context, {}, {}, ec) + return evaluate(ast, context, { transforms: {}, functions: {}, contextFunctions: {} }, ec) } describe('evaluator - literals', () => { diff --git a/tests/property.test.ts b/tests/property.test.ts index e21b04a..efda8e8 100644 --- a/tests/property.test.ts +++ b/tests/property.test.ts @@ -175,8 +175,8 @@ describe('property-based evaluator invariants', () => { const optimized = compile(parsed) const compiled = expr.compile(source) - const direct = captureOutcome(() => evaluate(parsed, { ...CONTEXT }, {}, {}, new ExecutionContext(new SecurityPolicy()))) - const optimizedDirect = captureOutcome(() => evaluate(optimized, { ...CONTEXT }, {}, {}, new ExecutionContext(new SecurityPolicy()))) + const direct = captureOutcome(() => evaluate(parsed, { ...CONTEXT }, { transforms: {}, functions: {}, contextFunctions: {} }, new ExecutionContext(new SecurityPolicy()))) + const optimizedDirect = captureOutcome(() => evaluate(optimized, { ...CONTEXT }, { transforms: {}, functions: {}, contextFunctions: {} }, new ExecutionContext(new SecurityPolicy()))) const syncResult = captureOutcome(() => expr.evaluateSync(source, { ...CONTEXT })) const compiledResult = captureOutcome(() => compiled.evaluateSync({ ...CONTEXT })) const asyncResult = await captureAsyncOutcome(() => expr.evaluate(source, { ...CONTEXT })) From 16a268704734b7721e9e82962b53fa78f06d5e5b Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Wed, 13 May 2026 22:16:54 +0100 Subject: [PATCH 2/3] fix(lint): satisfy oxlint type-aware rules CI activates oxlint-tsgolint (already in bun.lock as an optional peer of oxlint), which adds 8 extra rules that were not running locally. This commit clears the resulting violations: 4 mine and 3 pre-existing across the codebase, plus the matching u-flag regex warnings. - Drop unused type imports (ValidationResult, TransformFn, FunctionFn) from src/index.ts; they were already re-exported via export type. - Rename _shared -> sharedInstance in src/index.ts. - Rename __dirname -> scriptDir in scripts/check-package.ts. - Rename _typed / _untyped -> typed / untyped in the type tests. - Add the u flag to every regex literal across src, scripts, and tests. --- scripts/check-package.ts | 4 ++-- src/autocomplete/index.ts | 4 ++-- src/autocomplete/tokenizer.ts | 6 +++--- src/index.ts | 9 +++------ src/parser.ts | 2 +- tests/adversarial.test.ts | 10 +++++----- tests/context-functions-types.test.ts | 8 ++++---- tests/context-functions.test.ts | 10 +++++----- tests/method-calls.test.ts | 2 +- 9 files changed, 26 insertions(+), 29 deletions(-) diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 43552c9..2a8a854 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -const __dirname = dirname(fileURLToPath(import.meta.url)) -const root = resolve(__dirname, '..') +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const root = resolve(scriptDir, '..') function writeLine(message: string): void { process.stdout.write(`${message}\n`) diff --git a/src/autocomplete/index.ts b/src/autocomplete/index.ts index 9e56621..e948d1d 100644 --- a/src/autocomplete/index.ts +++ b/src/autocomplete/index.ts @@ -29,7 +29,7 @@ export interface AutocompleteInstance { } // Valid identifier pattern for transform name validation -const VALID_IDENTIFIER = /^[a-zA-Z_$][\w$]*$/ +const VALID_IDENTIFIER = /^[a-zA-Z_$][\w$]*$/u /** Check if an error is an expected Bonsai error (syntax, security, type, or reference). */ function isExpectedError(err: unknown): boolean { @@ -456,7 +456,7 @@ function inferPipeInputType( onError?: ErrorHandler, ): InferredTypeName | undefined { const before = expression.slice(0, cursor) - const pipeMatch = before.match(/^(.*)\|>\s*\w*\s*$/s) + const pipeMatch = before.match(/^(.*)\|>\s*\w*\s*$/su) if (!pipeMatch) return undefined const exprBefore = pipeMatch[1].trim() diff --git a/src/autocomplete/tokenizer.ts b/src/autocomplete/tokenizer.ts index ac30fda..50cd7bc 100644 --- a/src/autocomplete/tokenizer.ts +++ b/src/autocomplete/tokenizer.ts @@ -96,7 +96,7 @@ export function isCursorInsideString(expression: string, cursor: number): boolea return inSingle || inDouble || (inTemplate && templateDepth === 0) } -const TOKEN_RE = /\?\.|\.\.\.|\|>|\?\?|&&|\|\||[!=<>]=?|[+\-*/%]|\*\*|[.(){}[\],?:]|"(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\$]|\\.|\$(?!\{))*`?|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?|[a-zA-Z_$][\w$]*/g +const TOKEN_RE = /\?\.|\.\.\.|\|>|\?\?|&&|\|\||[!=<>]=?|[+\-*/%]|\*\*|[.(){}[\],?:]|"(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\$]|\\.|\$(?!\{))*`?|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?|[a-zA-Z_$][\w$]*/gu function regexScan(expression: string, cursor: number): NonEofToken[] { const tokens: NonEofToken[] = [] @@ -121,8 +121,8 @@ function classifyToken(value: string, start: number, end: number): NonEofToken | if (value === 'true' || value === 'false') return { type: 'Boolean', value, start, end } if (value === 'null') return { type: 'Null', value, start, end } if (value === 'undefined') return { type: 'Undefined', value, start, end } - if (/^[a-zA-Z_$]/.test(value)) return { type: 'Identifier', value, start, end } - if (/^\d/.test(value)) return { type: 'Number', value, start, end } + if (/^[a-zA-Z_$]/u.test(value)) return { type: 'Identifier', value, start, end } + if (/^\d/u.test(value)) return { type: 'Number', value, start, end } if (value.startsWith('"') || value.startsWith("'")) return { type: 'String', value, start, end } if (value.startsWith('`')) return { type: 'TemplateLiteral', value, start, end } if (value.length === 1 && '(){}[],:?.'.includes(value)) return { type: 'Punctuation', value: value as PunctuationValue, start, end } diff --git a/src/index.ts b/src/index.ts index caf79b5..80c961f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,10 +4,7 @@ import type { BonsaiInstance, CompiledExpression, EvaluationContextArgs, - ValidationResult, ExpressionReferences, - TransformFn, - FunctionFn, ContextFunctionFn, BonsaiPlugin, ASTNode, @@ -47,15 +44,15 @@ export type { const DEFAULT_CACHE_SIZE = 256 // Shared instance for standalone one-off evaluation -let _shared: BonsaiInstance | undefined +let sharedInstance: BonsaiInstance | undefined /** * Evaluate a single expression with default options. Uses a shared instance internally. * For repeated evaluation or custom configuration, use `bonsai()` to create a dedicated instance. */ export function evaluateExpression(expression: string, context?: Record): T { - if (!_shared) _shared = bonsai() - return _shared.evaluateSync(expression, context) + if (!sharedInstance) sharedInstance = bonsai() + return sharedInstance.evaluateSync(expression, context) } /** diff --git a/src/parser.ts b/src/parser.ts index 591e991..987b480 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -317,7 +317,7 @@ export function parse(source: string, _depth = 0): ASTNode { // Number if (tok.type === 'Number') { advance() - const raw = tok.value.replace(/_/g, '') + const raw = tok.value.replace(/_/gu, '') return { type: 'NumberLiteral', value: Number(raw), start: tok.start, end: tok.end } } diff --git a/tests/adversarial.test.ts b/tests/adversarial.test.ts index cd24e20..21abb8f 100644 --- a/tests/adversarial.test.ts +++ b/tests/adversarial.test.ts @@ -149,13 +149,13 @@ describe('sandbox hardening', () => { it('evaluateSync rejects Promise return from async functions', () => { const expr = bonsai() expr.addFunction('slow', async () => 'done') - expect(() => expr.evaluateSync('slow()')).toThrow(/synchronous function result/) + expect(() => expr.evaluateSync('slow()')).toThrow(/synchronous function result/u) }) it('evaluateSync rejects Promise return from async transforms', () => { const expr = bonsai() expr.addTransform('asyncUpper', async (val: unknown) => (val as string).toUpperCase()) - expect(() => expr.evaluateSync('"hello" |> asyncUpper')).toThrow(/synchronous transform result/) + expect(() => expr.evaluateSync('"hello" |> asyncUpper')).toThrow(/synchronous transform result/u) }) it('sync Promise rejection errors identify the call kind and suggest evaluate()', () => { @@ -163,9 +163,9 @@ describe('sandbox hardening', () => { expr.addFunction('asyncFn', async () => 1) expr.addTransform('asyncTx', async (v: unknown) => v) - expect(() => expr.evaluateSync('asyncFn()')).toThrow(/synchronous function result/) - expect(() => expr.evaluateSync('"x" |> asyncTx')).toThrow(/synchronous transform result/) - expect(() => expr.evaluateSync('asyncFn()')).toThrow(/evaluate\(\)/) + expect(() => expr.evaluateSync('asyncFn()')).toThrow(/synchronous function result/u) + expect(() => expr.evaluateSync('"x" |> asyncTx')).toThrow(/synchronous transform result/u) + expect(() => expr.evaluateSync('asyncFn()')).toThrow(/evaluate\(\)/u) }) it('async lambdas correctly await async function calls', async () => { diff --git a/tests/context-functions-types.test.ts b/tests/context-functions-types.test.ts index 1d26f4a..91d60e8 100644 --- a/tests/context-functions-types.test.ts +++ b/tests/context-functions-types.test.ts @@ -73,10 +73,10 @@ describe('addContextFunction / bonsai() type surface', () => { it('BonsaiInstance is generic over context', () => { interface Ctx { a: number } - const _typed: BonsaiInstance = bonsai() - const _untyped: BonsaiInstance = bonsai() - expect(typeof _typed.addContextFunction).toBe('function') - expect(typeof _untyped.addContextFunction).toBe('function') + const typed: BonsaiInstance = bonsai() + const untyped: BonsaiInstance = bonsai() + expect(typeof typed.addContextFunction).toBe('function') + expect(typeof untyped.addContextFunction).toBe('function') }) it('CompiledExpression is generic over context', () => { diff --git a/tests/context-functions.test.ts b/tests/context-functions.test.ts index a078500..9f78a61 100644 --- a/tests/context-functions.test.ts +++ b/tests/context-functions.test.ts @@ -98,7 +98,7 @@ describe('addContextFunction', () => { it('rejects async context functions in evaluateSync with a helpful error', () => { const expr = bonsai<{ value: number }>() expr.addContextFunction('lookup', async (ctx) => ctx.value) - expect(() => expr.evaluateSync('lookup()', { value: 1 })).toThrow(/lookup/) + expect(() => expr.evaluateSync('lookup()', { value: 1 })).toThrow(/lookup/u) }) it('supports parallel-safe context across async functions', async () => { @@ -202,7 +202,7 @@ describe('addContextFunction', () => { throw new Error('should have thrown') } catch (err) { expect(err).toBeInstanceOf(BonsaiReferenceError) - expect((err as Error).message).toMatch(/lookupUser/) + expect((err as Error).message).toMatch(/lookupUser/u) } }) @@ -216,7 +216,7 @@ describe('addContextFunction', () => { } catch (err) { expect(err).toBeInstanceOf(BonsaiReferenceError) // The suggestion should reach into the context-function namespace too - expect((err as Error).message).toMatch(/ctxfn/) + expect((err as Error).message).toMatch(/ctxfn/u) } }) }) @@ -251,13 +251,13 @@ describe('addContextFunction', () => { it('errors thrown from async functions propagate via the returned promise', async () => { const expr = bonsai<{ userId: string }>() expr.addContextFunction('boom', async () => { throw new Error('async-boom') }) - await expect(expr.evaluate('boom()', { userId: 'u_1' })).rejects.toThrow(/async-boom/) + await expect(expr.evaluate('boom()', { userId: 'u_1' })).rejects.toThrow(/async-boom/u) }) it('reports the function name when evaluateSync sees a promise from a context function', () => { const expr = bonsai<{ tier: string }>() expr.addContextFunction('asyncCtx', async (ctx) => ctx.tier) - expect(() => expr.evaluateSync('asyncCtx()', { tier: 'pro' })).toThrow(/asyncCtx/) + expect(() => expr.evaluateSync('asyncCtx()', { tier: 'pro' })).toThrow(/asyncCtx/u) }) }) diff --git a/tests/method-calls.test.ts b/tests/method-calls.test.ts index d727c0d..2b97f95 100644 --- a/tests/method-calls.test.ts +++ b/tests/method-calls.test.ts @@ -135,7 +135,7 @@ describe('method calls on values', () => { describe('argument validation', () => { const expr = bonsai() expr.addFunction('makeFn', () => () => 'injected') - expr.addFunction('makeRegex', () => /x/) + expr.addFunction('makeRegex', () => /x/u) it('blocks function args to replace', () => { expect(() => expr.evaluateSync('"abc".replace("a", makeFn())')).toThrow('callbacks are not allowed') From 1c38772a9b89f8ab1470ec9756ab1b692c6b848a Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Wed, 13 May 2026 22:24:59 +0100 Subject: [PATCH 3/3] docs(website): document context-aware functions and typed instances Updates the website docs, landing page, and LLM-readable docs for the new addContextFunction API and bonsai() context-type generic. - New "Context-aware functions" section in docs.html with usage, frozen context semantics, namespace behavior, and the isContextFunction introspection helper. - Typed-generics section expanded with bonsai() examples and a note that required context fields make the context argument required at call sites. - Instance Methods table gains addContextFunction and isContextFunction with updated descriptions for the shared pure/context namespace. - Common Tasks card surfaces addContextFunction alongside addFunction. - llms.txt and llms-full.txt mirror the new API for LLM consumers. - Landing page feature card mentions context-aware functions. Playground is unchanged. It evaluates expressions against ad-hoc context only, so no registration UI is involved. --- website/docs.html | 72 ++++++++++++++++++++++++++++++++++++++----- website/index.html | 2 +- website/llms-full.txt | 35 ++++++++++++++++++++- website/llms.txt | 3 +- 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/website/docs.html b/website/docs.html index 8a8cb2b..9666bd8 100644 --- a/website/docs.html +++ b/website/docs.html @@ -239,8 +239,8 @@

Check syntax before storing expressions

Add app logic

Register custom transforms and functions

-

Use this when the built-in syntax is not enough and you want app-specific capabilities.

- expr.addFunction('discount', fn) +

Use this when the built-in syntax is not enough and you want app-specific capabilities, including functions that read the evaluation context directly.

+ expr.addFunction('discount', fn)
expr.addContextFunction('hasPermission', fn)
@@ -865,8 +865,21 @@

Typed generics

}) +

The instance itself can also be generic over the context type. Pass bonsai<AppContext>() and the context shape is checked end-to-end through evaluate, evaluateSync, compile, and addContextFunction:

+ +
+interface AppContext { userId: string; perms: readonly string[] } + +const app = bonsai<AppContext>() + +await app.evaluate('userId == "u_1"', { userId: 'u_1', perms: [] }) // ✓ +await app.evaluate('userId == "u_1"', { foo: 'bar' }) // ✗ TS error +
+ +

When AppContext has required fields, the context argument becomes required at every call site; when fields are optional (or the default Record<string, unknown>), it stays optional, preserving the untyped ergonomics.

+
- Tip: If any registered transform, function, or method returns a Promise, evaluateSync() will throw an BonsaiTypeError identifying the offending call and suggesting evaluate() instead. Use evaluate() or compiled .evaluate() for async extensions. + Tip: If any registered transform, function, or method returns a Promise, evaluateSync() will throw a BonsaiTypeError identifying the offending call and suggesting evaluate() instead. Use evaluate() or compiled .evaluate() for async extensions.
@@ -1003,6 +1016,45 @@

Functions

}) // true +

Context-aware functions

+

Some functions need to read the evaluation context directly (think auth, permissions, personalization). Register them with addContextFunction: the function receives a shallow-frozen snapshot of the context as its first parameter, followed by the call's arguments.

+ +
+type ContextFunctionFn<TCtx> = + (context: Readonly<TCtx>, ...args: unknown[]) => unknown | Promise<unknown> +
+ +
+interface AppContext { + currentUserId: string + perms: readonly string[] +} + +const app = bonsai<AppContext>() + +app.addContextFunction('lookupCurrentUserTier', async (ctx) => { + const row = await db.users.findById(ctx.currentUserId) + return row?.tier ?? 'free' +}) + +app.addContextFunction('hasPermission', (ctx, action) => + ctx.perms.includes(String(action)) +) + +await app.evaluate( + 'lookupCurrentUserTier() == "pro" && hasPermission("admin")', + { currentUserId: 'u_123', perms: ['admin', 'write'] } +) +
+ +

Pure functions (addFunction) and context-aware functions share a single namespace. Registering the same name with either method overwrites the prior registration. Use isContextFunction(name) for introspection.

+ +

The frozen-context snapshot is created lazily once per evaluation and shared across every context-function call in that evaluation. Pure-function-only expressions never pay any shallow-copy cost.

+ +
+ Tip: typing the instance with bonsai<AppContext>() propagates AppContext through every method that touches context, so evaluate, evaluateSync, compile, and addContextFunction are all checked against the same shape. If your context type has required fields, the context argument becomes required at call sites. +
+

Plugins

@@ -1016,7 +1068,9 @@

Plugins

bonsai().use(currency)
-

Registering the same name again replaces the previous implementation. Transforms and functions live in separate registries, so the same name can exist once in each namespace.

+

Registering the same name again replaces the previous implementation. Transforms live in a separate namespace; pure and context-aware functions share one namespace and overwrite each other.

+ +

Plugins can also be typed against a minimal context they require: BonsaiPlugin<{ tenantId: string }> applies cleanly to any instance whose context extends { tenantId: string }.

Design rule: keep transforms small and predictable, validate their inputs, and reserve async behavior for cases where you genuinely need I/O. Custom extensions run as normal host JavaScript, so treat them as trusted code. @@ -1032,13 +1086,15 @@

Instance Methods #

use(plugin)thisApply a plugin immediately and return the same instance for chaining. addTransform(name, fn)thisRegister or replace a transform. - addFunction(name, fn)thisRegister or replace a function. + addFunction(name, fn)thisRegister or replace a pure function. + addContextFunction(name, fn)thisRegister or replace a context-aware function. Shares a namespace with addFunction. removeTransform(name)booleanUnregister a transform. Returns true if it existed. - removeFunction(name)booleanUnregister a function. Returns true if it existed. + removeFunction(name)booleanUnregister a function (pure or context-aware). Returns true if it existed. hasTransform(name)booleanCheck if a transform is registered. - hasFunction(name)booleanCheck if a function is registered. + hasFunction(name)booleanCheck if a function (pure or context-aware) is registered. + isContextFunction(name)booleanCheck whether a registered function reads the evaluation context. listTransforms()string[]List all registered transform names. - listFunctions()string[]List all registered function names. + listFunctions()string[]List all registered function names (both pure and context-aware). clearCache()voidClear the internal AST cache and compiled-expression cache. diff --git a/website/index.html b/website/index.html index ccf6c2b..c9a7569 100644 --- a/website/index.html +++ b/website/index.html @@ -251,7 +251,7 @@

Syntax

⚙️

API Reference

-

bonsai(), evaluateSync, compile(), validate(), transforms, and functions.

+

bonsai(), evaluateSync, compile(), validate(), transforms, functions, and context-aware functions.

📚
diff --git a/website/llms-full.txt b/website/llms-full.txt index 41c8774..b87bbf3 100644 --- a/website/llms-full.txt +++ b/website/llms-full.txt @@ -420,7 +420,7 @@ expr.evaluateSync('"ha" |> repeat(3)') // "hahaha" ### addFunction(name, fn) -Register a function called directly by name. +Register a pure function called directly by name. ```js expr.addFunction('greet', (name) => `Hello, ${name}!`) @@ -430,6 +430,39 @@ expr.evaluateSync('greet("world")') // "Hello, world!" expr.evaluateSync('clamp(150, 0, 100)') // 100 ``` +### addContextFunction(name, fn) + +Register a function that reads the evaluation context. The function receives a shallow-frozen snapshot of the context as its first parameter, followed by the call's arguments. Pure functions and context-aware functions share a single namespace. + +```ts +interface AppContext { + currentUserId: string + perms: readonly string[] +} + +const app = bonsai() + +app.addContextFunction('lookupCurrentUserTier', async (ctx) => { + const row = await db.users.findById(ctx.currentUserId) + return row?.tier ?? 'free' +}) + +app.addContextFunction('hasPermission', (ctx, action) => + ctx.perms.includes(String(action)) +) + +await app.evaluate( + 'lookupCurrentUserTier() == "pro" && hasPermission("admin")', + { currentUserId: 'u_123', perms: ['admin', 'write'] } +) +``` + +Typing the instance with `bonsai()` propagates the context shape through `evaluate`, `evaluateSync`, `compile`, and `addContextFunction` for end-to-end type safety. When the context type has required fields, the context argument becomes required at every call site. + +The frozen-context snapshot is created lazily once per evaluation and shared across every context-function call within that evaluation. Pure-function-only expressions never pay any shallow-copy cost. + +Use `isContextFunction(name)` to check whether a registered function reads the context. + ### use(plugin) Register a plugin. A plugin is a function that receives the Bonsai instance. diff --git a/website/llms.txt b/website/llms.txt index 7793619..a4ab4a2 100644 --- a/website/llms.txt +++ b/website/llms.txt @@ -55,7 +55,8 @@ expr.evaluateSync('`Hello ${name}!`', { name: 'world' }) // "Hello world!" - **Lambda predicates**: `.age >= 18` shorthand for `item => item.age >= 18` - **Template literals**: Backtick strings with `${expression}` interpolation - **Safe sandbox**: Blocks prototype access, enforces timeout/depth/array limits, own-property-only context lookup, null-prototype object literals, receiver-aware method validation, sync Promise guards -- **Plugin system**: Extend with custom transforms and functions +- **Plugin system**: Extend with custom transforms, pure functions, and context-aware functions +- **End-to-end typed context**: `bonsai()` propagates the context type through evaluation, compilation, and context-aware function registration - **Standard library**: Modular stdlib for strings, arrays, math, types, dates ## Documentation