From 7863aed3c75bc711f2d1299325df0566564c5046 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Wed, 29 Apr 2026 09:08:41 -0400 Subject: [PATCH 01/21] More changes, checkpoint! --- packages/gin/src/extension.ts | 6 +- packages/gin/src/node.ts | 56 ++++- packages/gin/src/type.ts | 6 +- packages/gin/src/types/and.ts | 4 +- packages/gin/src/types/any.ts | 4 +- packages/gin/src/types/bool.ts | 4 +- packages/gin/src/types/color.ts | 4 +- packages/gin/src/types/date.ts | 4 +- packages/gin/src/types/duration.ts | 4 +- packages/gin/src/types/enum.ts | 4 +- packages/gin/src/types/fn.ts | 4 +- packages/gin/src/types/generic.ts | 4 +- packages/gin/src/types/iface.ts | 4 +- packages/gin/src/types/list.ts | 4 +- packages/gin/src/types/literal.ts | 4 +- packages/gin/src/types/map.ts | 4 +- packages/gin/src/types/not.ts | 4 +- packages/gin/src/types/null.ts | 4 +- packages/gin/src/types/nullable.ts | 4 +- packages/gin/src/types/num.ts | 4 +- packages/gin/src/types/obj.ts | 4 +- packages/gin/src/types/optional.ts | 4 +- packages/gin/src/types/or.ts | 4 +- packages/gin/src/types/ref.ts | 4 +- packages/gin/src/types/text.ts | 4 +- packages/gin/src/types/timestamp.ts | 4 +- packages/gin/src/types/tuple.ts | 4 +- packages/gin/src/types/typ.ts | 12 +- packages/gin/src/types/void.ts | 4 +- packages/ginny/src/ai.ts | 1 + packages/ginny/src/context.ts | 32 ++- packages/ginny/src/fns-global.ts | 26 ++- packages/ginny/src/prompts/engineer.ts | 210 ++++++++++++++++-- packages/ginny/src/prompts/programmer.ts | 20 ++ packages/ginny/src/store.ts | 13 +- .../ginny/src/tools/find-or-create-fns.ts | 39 +++- packages/ginny/src/tools/finish.ts | 17 +- packages/ginny/src/tools/test.ts | 92 +++++++- packages/ginny/src/tools/write.ts | 52 ++++- 39 files changed, 560 insertions(+), 122 deletions(-) diff --git a/packages/gin/src/extension.ts b/packages/gin/src/extension.ts index 4ef9ecf..b5c2c34 100644 --- a/packages/gin/src/extension.ts +++ b/packages/gin/src/extension.ts @@ -21,7 +21,7 @@ import type { Scope } from './scope'; import type { Engine } from './engine'; import type { JSONOf, RuntimeOf } from './json-type'; import { z } from 'zod'; -import type { SchemaOptions } from './node'; +import type { SchemaOptions, ValueSchemaOptions } from './node'; import type { Expr } from './expr'; /** @@ -317,7 +317,7 @@ export class Extension extends Type { return this.local.constraint ? [this.local.constraint, ...base] : base; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Extensions normally delegate to base — but when `local.props` adds // data fields atop an object-shaped base (obj/iface), those fields need // to land in the value schema too. Nothing else in the pipeline pushes @@ -341,7 +341,7 @@ export class Extension extends Type { private mergeLocalPropsInto( schema: z.ZodTypeAny, - opts: SchemaOptions | undefined, + opts: ValueSchemaOptions | undefined, slotFor: (prop: Prop) => z.ZodTypeAny, ): z.ZodTypeAny { const props = this.local.props; diff --git a/packages/gin/src/node.ts b/packages/gin/src/node.ts index a9dee40..ff641b4 100644 --- a/packages/gin/src/node.ts +++ b/packages/gin/src/node.ts @@ -21,18 +21,21 @@ import type { Type } from './type'; * - `newStrict` — when true, NewExpr.toSchema emits a discriminated * union over `opts.types` instead of its generic shape. */ -export interface SchemaOptions { - Type: z.ZodTypeAny; - Expr: z.ZodTypeAny; - types: Type[]; - exprs: Expr[]; - /** - * Registry reference so schema builders can enumerate classes and - * registered named types (e.g. `NewExpr.toSchema` strict mode builds a - * union with branches per built-in class + per named instance). - */ - registry: Registry; - newStrict?: boolean; +/** + * Options consumed by `Type.toValueSchema` (and the helpers it delegates + * to like `describeType`). Deliberately narrower than `SchemaOptions`: + * value-side schema generation never references `Type` / `Expr` / + * `types` / `exprs` / `registry` / `newStrict`, so requiring them all + * just to pass `{ includeDocs: 'all' }` is overkill — and forces every + * caller to plumb the full meta-language schema bag through. + * + * Callers building a value-side schema for one Type (e.g. ginny's + * `test()` tool deriving its `args` schema from a function's params) + * can call `argsType.toValueSchema({ includeDocs: 'all' })` without + * holding onto the full `SchemaOptions`. `SchemaOptions` extends this, + * so existing call sites that already have the full bag still work. + */ +export interface ValueSchemaOptions { /** * Control whether Type docstrings are attached to generated Zod schemas * via `.describe(...)`. Useful for LLM prompting — docs become part of @@ -44,6 +47,35 @@ export interface SchemaOptions { * with its own `docs`. */ includeDocs?: 'none' | 'type' | 'all'; + /** + * Optional pass-through to the full meta-language schema bag. Most + * `toValueSchema` paths never touch these — they're declared here so + * a `SchemaOptions` (where these are required) is structurally + * assignable to `ValueSchemaOptions` without casts, and so the rare + * type that DOES need them (e.g. `TypType.toValueSchema` building an + * inline-Extension branch) can read them off `opts` directly when + * present and gracefully degrade when not. + */ + Type?: z.ZodTypeAny; + Expr?: z.ZodTypeAny; + types?: Type[]; + exprs?: Expr[]; + registry?: Registry; + newStrict?: boolean; +} + +export interface SchemaOptions extends ValueSchemaOptions { + Type: z.ZodTypeAny; + Expr: z.ZodTypeAny; + types: Type[]; + exprs: Expr[]; + /** + * Registry reference so schema builders can enumerate classes and + * registered named types (e.g. `NewExpr.toSchema` strict mode builds a + * union with branches per built-in class + per named instance). + */ + registry: Registry; + newStrict?: boolean; /** * Control whether Expr comments are attached via `.describe(...)`. * - `'none'` (default): ignore. diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index ff76979..ea09aed 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -9,7 +9,7 @@ import { Problems } from './problem'; import type { Scope } from './scope'; import type { JSONOf, RuntimeOf } from './json-type'; import { z } from 'zod'; -import type { SchemaOptions } from './node'; +import type { SchemaOptions, ValueSchemaOptions } from './node'; // ============================================================================ // RUNTIME SPEC SHAPES @@ -556,7 +556,7 @@ export abstract class Type implements Node { * - `includeDocs: 'type' | 'all'` — attach `.describe(this.docs)` if * set. 'all' also describes individual props / fields / get / call. */ - abstract toValueSchema(opts?: SchemaOptions): z.ZodTypeAny; + abstract toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny; /** * Produce a Zod schema for the VALUE side of a `{ kind: 'new' }` Expr of @@ -584,7 +584,7 @@ export abstract class Type implements Node { */ protected describeType( schema: z.ZodTypeAny, - opts?: SchemaOptions, + opts?: ValueSchemaOptions, aidPrefix: 'Value_' | 'NewValue_' | null = 'Value_', ): z.ZodTypeAny { const mode = opts?.includeDocs ?? 'none'; diff --git a/packages/gin/src/types/and.ts b/packages/gin/src/types/and.ts index 196f39f..7862a05 100644 --- a/packages/gin/src/types/and.ts +++ b/packages/gin/src/types/and.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { Call, type CompatOptions, GetSet, type Prop, PropSpec, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; export interface AndOptions { @@ -167,7 +167,7 @@ export class AndType extends Type { return this.docsPrefix() + `and<${this.parts.map((p) => p.toCode()).join(', ')}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { if (this.parts.length === 0) return this.describeType(z.unknown(), opts); if (this.parts.length === 1) return this.describeType(this.parts[0]!.toValueSchema(opts), opts); const s = this.parts diff --git a/packages/gin/src/types/any.ts b/packages/gin/src/types/any.ts index 9a3c417..1658d63 100644 --- a/packages/gin/src/types/any.ts +++ b/packages/gin/src/types/any.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -100,7 +100,7 @@ export class AnyType extends Type> { toCode(): string { return this.docsPrefix() + 'any'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.any(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.any(), opts); } /** An instance of `any` is any TypeDef — only requires a `name: string`. */ toInstanceSchema(): z.ZodTypeAny { diff --git a/packages/gin/src/types/bool.ts b/packages/gin/src/types/bool.ts index 68a61ce..8a73597 100644 --- a/packages/gin/src/types/bool.ts +++ b/packages/gin/src/types/bool.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { BoolOptions } from '../builder'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -101,7 +101,7 @@ export class BoolType extends Type { toCode(): string { return this.docsPrefix() + 'bool' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.boolean(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.boolean(), opts); } toInstanceSchema(): z.ZodTypeAny { return z.object({ name: z.literal('bool') }).passthrough(); diff --git a/packages/gin/src/types/color.ts b/packages/gin/src/types/color.ts index 4ec1d74..a8faf9b 100644 --- a/packages/gin/src/types/color.ts +++ b/packages/gin/src/types/color.ts @@ -5,7 +5,7 @@ import { type CompatOptions, Init, type Prop, type Rnd, Type, optionsCode } from import type { ColorOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -132,7 +132,7 @@ export class ColorType extends Type { toCode(): string { return this.docsPrefix() + 'color' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is a 32-bit integer (0xRRGGBBAA or 0xRRGGBB depending on hasAlpha). return this.describeType(z.number().int().min(0).max(0xffffffff), opts); } diff --git a/packages/gin/src/types/date.ts b/packages/gin/src/types/date.ts index 901f995..2069d8f 100644 --- a/packages/gin/src/types/date.ts +++ b/packages/gin/src/types/date.ts @@ -5,7 +5,7 @@ import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../t import type { DateOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -146,7 +146,7 @@ export class DateType extends Type { toCode(): string { return this.docsPrefix() + 'date' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is an ISO date string (YYYY-MM-DD). return this.describeType( z.string().regex(/^\d{4}-\d{2}-\d{2}/, 'expected ISO 8601 date'), diff --git a/packages/gin/src/types/duration.ts b/packages/gin/src/types/duration.ts index 0e420fd..8d1ce00 100644 --- a/packages/gin/src/types/duration.ts +++ b/packages/gin/src/types/duration.ts @@ -3,7 +3,7 @@ import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, Init, type Prop, type Rnd, Type } from '../type'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -106,7 +106,7 @@ export class DurationType extends Type> { toCode(): string { return this.docsPrefix() + 'duration'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is a number of milliseconds. return this.describeType(z.number(), opts); } diff --git a/packages/gin/src/types/enum.ts b/packages/gin/src/types/enum.ts index ed1e31c..19d0c1e 100644 --- a/packages/gin/src/types/enum.ts +++ b/packages/gin/src/types/enum.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -159,7 +159,7 @@ export class EnumType extends Type> { return this.docsPrefix() + body; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Zod v4's z.enum accepts a Record — same shape as EnumOptions.values. return this.describeType( z.enum(this.options.values as Record), diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 85281e2..6b0a110 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderGenerics } from '../type'; import { decodeCall, encodeCall } from '../spec'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema } from '../schemas'; /** @@ -176,7 +176,7 @@ export class FnType extends Type> { + `${renderGenerics(this.generic)}(${formatParams(this._call.args)}): ${ret}`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Functions aren't JSON-serializable. Accept a native id (string) or // an inline lambda ExprDef (object with `kind`). LLMs shouldn't be // generating raw function values — use native id strings. diff --git a/packages/gin/src/types/generic.ts b/packages/gin/src/types/generic.ts index cf3f74e..1589d53 100644 --- a/packages/gin/src/types/generic.ts +++ b/packages/gin/src/types/generic.ts @@ -3,7 +3,7 @@ import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; export interface GenericOptions { @@ -102,7 +102,7 @@ export class GenericType extends Type { toCode(): string { return this.docsPrefix() + this.options.name; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Unbound placeholder — no concrete shape constraint. Callers that // need tight schemas should `.bind()` the generic first. return this.describeType(z.any(), opts); diff --git a/packages/gin/src/types/iface.ts b/packages/gin/src/types/iface.ts index 3a149f6..2a6b511 100644 --- a/packages/gin/src/types/iface.ts +++ b/packages/gin/src/types/iface.ts @@ -12,7 +12,7 @@ import { } from '../type'; import { decodeCall, decodeGetSet, decodeProps, encodeCall, encodeGetSet, encodeProps } from '../spec'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema, getSetDefSchema, propDefSchema } from '../schemas'; /** @@ -220,7 +220,7 @@ export class IfaceType extends Type> { return this.docsPrefix() + body; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const mode = opts?.includeDocs ?? 'none'; // Structural: any object carrying the declared props is acceptable. const shape: Record = {}; diff --git a/packages/gin/src/types/list.ts b/packages/gin/src/types/list.ts index 8ebfaa3..0886964 100644 --- a/packages/gin/src/types/list.ts +++ b/packages/gin/src/types/list.ts @@ -5,7 +5,7 @@ import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } fr import type { ListOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue } from '../json-type'; @@ -226,7 +226,7 @@ export class ListType extends Type { return this.docsPrefix() + `list<${this.item.toCode()}>` + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = z.array(this.item.toValueSchema(opts)); if (this.options.minLength !== undefined) s = s.min(this.options.minLength); if (this.options.maxLength !== undefined) s = s.max(this.options.maxLength); diff --git a/packages/gin/src/types/literal.ts b/packages/gin/src/types/literal.ts index 2d12585..840bb54 100644 --- a/packages/gin/src/types/literal.ts +++ b/packages/gin/src/types/literal.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type PropSpec, type Rnd, Type, optionsCode } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -144,7 +144,7 @@ export class LiteralType extends Type> { + optionsCode({ value: this.literal }); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType( z.literal(this.literal as string | number | boolean | null), opts, diff --git a/packages/gin/src/types/map.ts b/packages/gin/src/types/map.ts index b464475..322c7d9 100644 --- a/packages/gin/src/types/map.ts +++ b/packages/gin/src/types/map.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue } from '../json-type'; @@ -184,7 +184,7 @@ export class MapType extends Type, Record`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // LLM-friendly shape: an array of { key, value } objects. Not a // positional tuple — LLMs handle object keys more reliably. return this.describeType(z.array(z.object({ diff --git a/packages/gin/src/types/not.ts b/packages/gin/src/types/not.ts index be8ef95..88cb031 100644 --- a/packages/gin/src/types/not.ts +++ b/packages/gin/src/types/not.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; export interface NotOptions { @@ -117,7 +117,7 @@ export class NotType extends Type { toCode(): string { return this.docsPrefix() + `not<${this.excluded.toCode()}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const excluded = this.excluded.toValueSchema(opts); return this.describeType(z.any().refine( (v) => !excluded.safeParse(v).success, diff --git a/packages/gin/src/types/null.ts b/packages/gin/src/types/null.ts index cb322bf..129b5d7 100644 --- a/packages/gin/src/types/null.ts +++ b/packages/gin/src/types/null.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -90,7 +90,7 @@ export class NullType extends Type> { toCode(): string { return this.docsPrefix() + 'null'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } toInstanceSchema(): z.ZodTypeAny { return z.object({ name: z.literal('null') }).passthrough(); diff --git a/packages/gin/src/types/nullable.ts b/packages/gin/src/types/nullable.ts index 7edaa1a..f070458 100644 --- a/packages/gin/src/types/nullable.ts +++ b/packages/gin/src/types/nullable.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -130,7 +130,7 @@ export class NullableType extends Type> return this.docsPrefix() + `nullable<${this.inner.toCode()}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(this.inner.toValueSchema(opts).nullable(), opts); } diff --git a/packages/gin/src/types/num.ts b/packages/gin/src/types/num.ts index 405b978..1b0f068 100644 --- a/packages/gin/src/types/num.ts +++ b/packages/gin/src/types/num.ts @@ -5,7 +5,7 @@ import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } fr import type { NumOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -234,7 +234,7 @@ export class NumType extends Type { toCode(): string { return this.docsPrefix() + 'num' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = this.options.whole ? z.number().int() : z.number(); if (this.options.min !== undefined) s = s.min(this.options.min); if (this.options.max !== undefined) s = s.max(this.options.max); diff --git a/packages/gin/src/types/obj.ts b/packages/gin/src/types/obj.ts index e78d36f..97d87a0 100644 --- a/packages/gin/src/types/obj.ts +++ b/packages/gin/src/types/obj.ts @@ -5,7 +5,7 @@ import { type CompatOptions, GetSet, Prop, type PropSpec, type Rnd, Type } from import { decodeProps, encodeProps } from '../spec'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue, RuntimeOf } from '../json-type'; import { propDefSchema } from '../schemas'; @@ -218,7 +218,7 @@ export class ObjType> extends Type = {}; for (const [name, prop] of Object.entries(this.fields)) { diff --git a/packages/gin/src/types/optional.ts b/packages/gin/src/types/optional.ts index d7724bd..d147454 100644 --- a/packages/gin/src/types/optional.ts +++ b/packages/gin/src/types/optional.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue, RuntimeOf } from '../json-type'; @@ -136,7 +136,7 @@ export class OptionalType extends Type`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(this.inner.toValueSchema(opts).optional(), opts); } diff --git a/packages/gin/src/types/or.ts b/packages/gin/src/types/or.ts index 910d960..e9ee3c7 100644 --- a/packages/gin/src/types/or.ts +++ b/packages/gin/src/types/or.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { Call, type CompatOptions, GetSet, type Prop, type PropSpec, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; export interface OrOptions { @@ -179,7 +179,7 @@ export class OrType extends Type { return this.docsPrefix() + `or<${this.variants.map((v) => v.toCode()).join(', ')}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { if (this.variants.length === 0) return this.describeType(z.never(), opts); if (this.variants.length === 1) return this.describeType(this.variants[0]!.toValueSchema(opts), opts); const schemas = this.variants.map((v) => v.toValueSchema(opts)) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]; diff --git a/packages/gin/src/types/ref.ts b/packages/gin/src/types/ref.ts index e176d20..60eb133 100644 --- a/packages/gin/src/types/ref.ts +++ b/packages/gin/src/types/ref.ts @@ -13,7 +13,7 @@ import { } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; export interface RefOptions { @@ -141,7 +141,7 @@ export class RefType extends Type { toCode(): string { return this.docsPrefix() + this.options.name; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Lazy so recursive named types (A → list) don't blow the stack. return this.describeType(z.lazy(() => this.resolve().toValueSchema(opts)), opts); } diff --git a/packages/gin/src/types/text.ts b/packages/gin/src/types/text.ts index 0f66fa1..b7bc1ae 100644 --- a/packages/gin/src/types/text.ts +++ b/packages/gin/src/types/text.ts @@ -5,7 +5,7 @@ import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } fr import type { TextOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -208,7 +208,7 @@ export class TextType extends Type { toCode(): string { return this.docsPrefix() + 'text' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = z.string(); if (this.options.minLength !== undefined) s = s.min(this.options.minLength); if (this.options.maxLength !== undefined) s = s.max(this.options.maxLength); diff --git a/packages/gin/src/types/timestamp.ts b/packages/gin/src/types/timestamp.ts index c858904..a0b3e67 100644 --- a/packages/gin/src/types/timestamp.ts +++ b/packages/gin/src/types/timestamp.ts @@ -5,7 +5,7 @@ import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../t import type { TimestampOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -123,7 +123,7 @@ export class TimestampType extends Type { toCode(): string { return this.docsPrefix() + 'timestamp' + optionsCode(this.options); } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is ISO 8601 datetime with a REQUIRED time component: // YYYY-MM-DD[T or space]HH:MM[:SS[.fff]][Z|±HH:MM] return this.describeType(z.string().regex( diff --git a/packages/gin/src/types/tuple.ts b/packages/gin/src/types/tuple.ts index a536df8..08ec4c0 100644 --- a/packages/gin/src/types/tuple.ts +++ b/packages/gin/src/types/tuple.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONValue } from '../json-type'; @@ -170,7 +170,7 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return this.docsPrefix() + `tuple<${this.elements.map((e) => e.toCode()).join(', ')}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Tuples ARE positional by nature — emit z.tuple for fidelity. LLM // consumers that struggle with positional arrays should use a different // shape (obj with named fields); tuple type preserves the position diff --git a/packages/gin/src/types/typ.ts b/packages/gin/src/types/typ.ts index 1949539..edbe849 100644 --- a/packages/gin/src/types/typ.ts +++ b/packages/gin/src/types/typ.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { Value } from '../value'; import { extensionSchemaNarrowed } from '../schemas'; @@ -137,7 +137,7 @@ export class TypType extends Type> { return this.docsPrefix() + `typ<${this.constraint.toCode()}>`; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const narrowed = this.registry.like(this.constraint); // If no registry type is compatible with the constraint, nothing can // pass — emit never so callers can't supply an arbitrary TypeDef. @@ -150,8 +150,12 @@ export class TypType extends Type> { const compatibleNames = this.registry .compatible(this.constraint) .map((t) => t.name); - const inlineExt = opts && compatibleNames.length > 0 - ? extensionSchemaNarrowed(this.registry, opts, compatibleNames) + // `extensionSchemaNarrowed` needs the full meta-language schema bag + // (`Type` + `Expr`). When toValueSchema is called with only the + // narrow ValueSchemaOptions, gracefully drop the inline-extension + // branch — the base instance schema is still correct. + const inlineExt = opts?.Type && opts?.Expr && compatibleNames.length > 0 + ? extensionSchemaNarrowed(this.registry, opts as SchemaOptions, compatibleNames) : null; const schema = inlineExt diff --git a/packages/gin/src/types/void.ts b/packages/gin/src/types/void.ts index de123bc..5b53bf2 100644 --- a/packages/gin/src/types/void.ts +++ b/packages/gin/src/types/void.ts @@ -4,7 +4,7 @@ import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions } from '../node'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -89,7 +89,7 @@ export class VoidType extends Type> { toCode(): string { return this.docsPrefix() + 'void'; } - toValueSchema(opts?: SchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } toInstanceSchema(): z.ZodTypeAny { return z.object({ name: z.literal('void') }).passthrough(); diff --git a/packages/ginny/src/ai.ts b/packages/ginny/src/ai.ts index ad2f4e6..86d39b3 100644 --- a/packages/ginny/src/ai.ts +++ b/packages/ginny/src/ai.ts @@ -161,6 +161,7 @@ export const ai = AI.with() loadedFns: sessionLoadedFns, loadedVars: sessionLoadedVars, runState: createRunState(), + programmerDepth: 0, }, providedContext: async (ctx) => ({ ...ctx, diff --git a/packages/ginny/src/context.ts b/packages/ginny/src/context.ts index af4f7bb..6e1f945 100644 --- a/packages/ginny/src/context.ts +++ b/packages/ginny/src/context.ts @@ -1,4 +1,4 @@ -import type { Registry, Engine, Type, Value } from '@aeye/gin'; +import type { Registry, Engine, Type, Value, ObjType } from '@aeye/gin'; import type { Store } from './store'; import type { RunState } from './run-state'; @@ -21,6 +21,36 @@ export interface Ctx { * reject the returned promise when it fires. */ ask?: (question: string, signal?: AbortSignal) => Promise; + /** + * How many programmer invocations deep we are. Top-level (REPL) is 0; + * each `engineer.create_new_fn` increments by 1 before invoking + * programmer recursively. The `find_or_create_functions` tool gates + * its `applicable` on this so a recursive programmer at the cap + * can't keep delegating function creation back to itself — it has to + * write the function inline. + */ + programmerDepth?: number; + /** + * Set by `engineer.create_new_fn` before invoking the inner programmer. + * Tells `test()` how to wrap raw scope args into typed `Value`s and + * tells `finish()` what signature to use when persisting the draft — + * so the saved fn matches what the engineer designed instead of being + * `(): or` (an inference of the body's static type). + * + * `argsType` is intentionally `ObjType`, not the generic `Type`: a + * gin function's arguments are always an obj whose props ARE the + * parameter list. Typing it concretely lets downstream tools read + * `argsType.fields` and call `argsType.parse(rawArgs)` without + * narrowing checks, and forces `engineer.create_new_fn` to validate + * the input up front. + */ + targetFn?: { name: string; argsType: ObjType; returnsType: Type }; } +/** Hard cap on programmer recursion. With 0-indexed depth, programmers + * at depth < MAX_PROGRAMMER_DEPTH - 1 can delegate to the engineer to + * create more programmers; the deepest one cannot. Set to 3 → max 3 + * programmers in the stack. */ +export const MAX_PROGRAMMER_DEPTH = 3; + export interface Meta {} diff --git a/packages/ginny/src/fns-global.ts b/packages/ginny/src/fns-global.ts index d43c177..5212232 100644 --- a/packages/ginny/src/fns-global.ts +++ b/packages/ginny/src/fns-global.ts @@ -1,5 +1,4 @@ -import type { ExprDef, Type, Value } from '@aeye/gin'; -import type { Ctx } from './context'; +import type { Engine, ExprDef, Type, Value } from '@aeye/gin'; /** * Wire a saved gin function (`fns/.json`) into the engine as a @@ -12,19 +11,28 @@ import type { Ctx } from './context'; * recursive, can reference other globals (`vars.*`, other saved fns), * and stay decoupled from the parent program's scope. * - * Parts of `vars-global.ts` already use the same `engine.registerGlobal` - * API — fns and vars share the global namespace, so a fn name can't - * collide with a var name. + * Takes `engine` directly rather than the dynamic `ctx` so this helper + * doesn't need to import the (purposely loose) ctx type. */ export function registerFnAsGlobal( - ctx: Ctx, + engine: Engine, name: string, type: Type, body: ExprDef, ): void { const callable = async (argsValue: Value): Promise => { - const args = (argsValue?.raw ?? {}) as Record; - return await ctx.engine.run(body, args); + // Gin's calling convention: a function's parameters are bound as a + // single `args` scope var (matches how `Lambda.evaluate` does it + // in @aeye/gin/exprs/lambda.ts). The body accesses individual + // params via `[{prop: 'args'}, {prop: ''}]`. + // + // The single-namespace wrapping is deliberate: param names are + // controlled by the caller (engineer/programmer), but a function + // body also has globals (`fns`, `vars`, loaded fns), `recurse`, + // and lambda-context names (`this`, `super`, `key`, `value`) in + // scope. Putting params under `args.*` keeps any of those names + // free for use as parameters without collision risk. + return await engine.run(body, { args: argsValue }); }; - ctx.engine.registerGlobal(name, { type, value: callable }); + engine.registerGlobal(name, { type, value: callable }); } diff --git a/packages/ginny/src/prompts/engineer.ts b/packages/ginny/src/prompts/engineer.ts index d0895e4..0a7dc39 100644 --- a/packages/ginny/src/prompts/engineer.ts +++ b/packages/ginny/src/prompts/engineer.ts @@ -1,9 +1,13 @@ import { z } from 'zod'; -import type { Message } from '@aeye/core'; +import { ToolInterrupt, type Message } from '@aeye/core'; +import { buildSchemas, ObjType } from '@aeye/gin'; +import type { TypeDef, Type } from '@aeye/gin'; import { ai } from '../ai'; import { modelFor } from '../model-selection'; import { ask } from '../tools/ask'; import { runSubagent } from '../progress'; +import { MAX_PROGRAMMER_DEPTH } from '../context'; +import { createRunState } from '../run-state'; const searchFns = ai.tool({ name: 'search_fns', @@ -39,23 +43,147 @@ const getFn = ai.tool({ const createNewFn = ai.tool({ name: 'create_new_fn', description: 'Spin up a programmer to implement a new function and persist it.', - instructions: 'Create a new reusable function by recursively invoking the programmer.', - schema: z.object({ - name: z.string().describe('Unique function name'), - description: z.string().describe('What the function should do'), - }), - call: async (input: { name: string; description: string }, _refs, ctx) => { + instructions: + 'Create a new reusable function by recursively invoking the programmer. ' + + 'Decide the function\'s signature here (args type, return type) — the programmer needs it ' + + 'spelled out so it knows exactly what body to write instead of looping back through find_or_create_functions. ' + + 'CRITICAL: every value the function should operate on must be a parameter in `args`. ' + + 'If the user wrote "compute prime factors of a number", the number is a PARAMETER (`{ name: "obj", props: { n: { type: { name: "num" } } } }`) — ' + + 'do NOT bake a sample like 56 into the body. The whole point of saving a function is so the user can call it again with different inputs.', + schema: (ctx) => { + const opts = buildSchemas(ctx.registry); + return z.object({ + name: z.string().describe('Unique function name (camelCase)'), + description: z.string().describe('What the function should do'), + args: (opts.Type as z.ZodType).describe( + 'TypeDef of the function\'s parameter object. The PROPS of this obj ARE the function\'s parameters — ' + + 'each prop becomes a scope variable in the body when the function is called. ' + + 'Examples: function taking one number → `{ name: "obj", props: { n: { type: { name: "num" } } } }`; ' + + 'function taking text and a list → `{ name: "obj", props: { name: { type: { name: "text" } }, items: { type: { name: "list", generic: { V: { name: "any" } } } } } }`. ' + + 'Only use `{ name: "obj" }` (empty obj, no props) for genuinely nullary functions — most useful functions have parameters, so default to listing them as props.', + ), + returns: (opts.Type as z.ZodType).describe( + 'TypeDef of the function\'s return value — e.g. `{ name: "list", generic: { V: { name: "num" } } }` for `list`.', + ), + }); + }, + // Defensive — the deepest programmer is supposed to write inline, but + // also block engineer.createNewFn at the cap in case a different path + // got us here. + applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, + call: async ( + input: { name: string; description: string; args: TypeDef; returns: TypeDef }, + _refs, + ctx, + ) => { const { programmer } = await import('./programmer'); - const request = `Create a reusable gin function named '${input.name}': ${input.description}. Write it as a program, test it, and finish.`; - // Programmer reads its task from ctx.messages now — start a fresh - // sub-conversation so the engineer's own messages don't leak in. + + // Parse the engineer-supplied signature into runtime Types — these + // are what `test()` uses to wrap raw scope args and what `finish()` + // saves as the function's persisted type. + let argsType: ObjType; + let returnsType: Type; + try { + const parsedArgs = ctx.registry.parse(input.args); + if (!(parsedArgs instanceof ObjType)) { + throw new Error(`expected an obj type, got '${parsedArgs.name}'`); + } + argsType = parsedArgs; + } catch (e: unknown) { + throw new ToolInterrupt( + `Could not parse args type for '${input.name}': ${e instanceof Error ? e.message : String(e)}. ` + + `args must be an obj type whose props are the function's parameters — e.g. \`{ name: "obj", props: { n: { type: { name: "num" } } } }\`.`, + ); + } + try { returnsType = ctx.registry.parse(input.returns); } catch (e: unknown) { + throw new ToolInterrupt(`Could not parse returns type for '${input.name}': ${e instanceof Error ? e.message : String(e)}`); + } + + const argsCode = (() => { try { return argsType.toCode(); } catch { return JSON.stringify(input.args); } })(); + const returnsCode = (() => { try { return returnsType.toCode(); } catch { return JSON.stringify(input.returns); } })(); + + // `argsType` is guaranteed to be an `ObjType` by the parse step + // above, so its `fields` map is the parameter list directly. + const paramNames: string[] = Object.keys(argsType.fields); + const paramList = paramNames.length === 0 + ? '(no parameters — body should produce a value of the return type with no inputs)' + : paramNames.map((p) => `\`${p}\``).join(', '); + + // Spell out the job in the recursive programmer's first user + // message so it has the full signature in scope and doesn't try to + // delegate back to find_or_create_functions / create_new_fn. + const request = [ + `You ARE the writer of this gin function. The engineer has already designed the signature; your job is to author the body. Do NOT call find_or_create_functions or delegate elsewhere.`, + ``, + `Function name: ${input.name}`, + `Args type: ${argsCode}`, + `Returns type: ${returnsCode}`, + `Description: ${input.description}`, + ``, + `## How parameters work`, + ``, + `Parameters are bound under a single \`args\` scope variable (the entire signature obj). To read a parameter, walk the path \`args.\`.`, + `- For \`(n: num, m: text): R\`, read \`n\` via \`{ kind: "get", path: [{ prop: "args" }, { prop: "n" }] }\`, and \`m\` via \`{ kind: "get", path: [{ prop: "args" }, { prop: "m" }] }\`.`, + `- DO NOT redeclare params with \`define\` — they are already bound under \`args\`.`, + `- DO NOT read a bare \`n\` or \`obj\` from scope; those names aren't there.`, + ``, + `Parameters available: ${paramList}`, + ``, + `## Inputs are PARAMETERS, not constants`, + ``, + `The body must operate on \`args.*\` — not on hardcoded sample values. test() will call your body with sample values to verify it works, but the SAVED body must compute its result from whichever values the caller passes in.`, + `- Wrong: \`define n = new num{value: 56}\` then loop on \`n\` — this hardcodes 56 forever.`, + `- Right: get('args').get('n') (i.e. path \`[{prop:"args"}, {prop:"n"}]\`), and operate on that.`, + `If you find yourself writing \`new num{value: }\`, ask whether that value should actually come from \`args\` — usually it should.`, + ``, + `## Steps`, + ``, + `1. \`write({ program: })\`.`, + `2. \`test({ args: { ${paramNames.map((p) => `${p}: `).join(', ')} } })\` — concrete sample values matching the args type; the args schema is auto-built from the function's args type.`, + `3. \`finish({ saveAs: '${input.name}' })\` once the test passes — this persists the body with the engineer-designed signature.`, + ].join('\n'); + + // Fresh sub-conversation, fresh runState we can read after the run + // finishes, bumped programmerDepth so the recursion cap kicks in, + // and `targetFn` so test()/finish() can specialize their behavior. const messages: Message[] = [{ role: 'user', content: request }]; + const childDepth = (ctx.programmerDepth ?? 0) + 1; + const innerRunState = createRunState(); + const innerCtx = { + ...ctx, + messages, + programmerDepth: childDepth, + runState: innerRunState, + targetFn: { name: input.name, argsType, returnsType }, + }; + await runSubagent( - `programmer: ${input.name}`, - () => programmer.get('stream', {}, { ...ctx, messages }), + `programmer: ${input.name} (depth ${childDepth})`, + () => programmer.get('stream', {}, innerCtx), ctx.signal, ); - return `Function '${input.name}' created.`; + + // Verify the inner programmer actually produced a working draft. + // Throw `ToolInterrupt` rather than returning a string — the AI + // runtime turns that into an error event, so the engineer's + // structured-output stage can't quietly include this name in + // `created` when the file was never written. (Returning a "did not + // succeed" string still counts as a successful tool call to the + // engineer, which led to ghost entries.) + if (!innerRunState.lastTest?.success) { + const why = innerRunState.lastTest?.error ?? 'no successful test was recorded'; + throw new ToolInterrupt( + `Function '${input.name}' was NOT created — programmer did not reach a passing test (${why}). ` + + `Refine the description / signature and try again, or do not include this name in your final \`created\` list.`, + ); + } + if (!ctx.loadedFns.has(input.name)) { + throw new ToolInterrupt( + `Function '${input.name}' was NOT saved — programmer reached a passing test but didn't call finish({ saveAs: '${input.name}' }). ` + + `Do not include this name in your final \`created\` list.`, + ); + } + return `Function '${input.name}' created (${argsCode} → ${returnsCode}). It is now safe to include '${input.name}' in your final \`created\` list.`; }, }); @@ -67,13 +195,65 @@ export const engineer = ai.prompt({ reusable gin functions. Find an existing function that matches the request or spin up a programmer to author a new one. +## Inputs become parameters + +When designing a new function, every value the function "operates on" +must become a parameter in \`args\` — not a constant inside the body. +A user asking for "a function that computes prime factors of a number" +wants a function they can call later as \`primeFactors({ n: 5 })\`, +\`primeFactors({ n: 56 })\`, etc. — so the right signature is +\`{ name: "obj", props: { n: { type: { name: "num" } } } }\` returning +\`list\`. Burying a sample value (like 56) inside the body would +make the function answer the same question forever. + +When in doubt: +- Anything the user said should be variable → parameter. +- Anything the user said is fixed (a constant, a known formula) → may + be a literal in the body. + +## Honest reporting + +\`use\` and \`created\` must reflect what is ACTUALLY available on disk: +- Only put a name in \`use\` if \`get_fn\` (or \`search_fns\` + \`get_fn\`) + confirmed it exists. +- Only put a name in \`created\` if \`create_new_fn\` returned successfully + for that name in this session. If \`create_new_fn\` raised an error, + the function was NOT written — do NOT claim it as created. The + programmer that consumes your output will load each name from disk + and break if you fabricate entries. + +If you couldn't satisfy the request, return empty arrays and let the +programmer write the work inline rather than claiming a non-existent +function. + Request: {{description}}`, input: (input: { description: string }) => ({ description: input.description }), tools: [searchFns, getFn, createNewFn, ask], toolIterations: 8, excludeMessages: true, schema: z.object({ - use: z.array(z.string()).default([]).describe('Names of existing functions to use'), - created: z.array(z.string()).default([]).describe('Names of newly created functions'), + use: z.array(z.string()).default([]).describe('Names of existing functions confirmed via get_fn / search_fns.'), + created: z.array(z.string()).default([]).describe('Names of functions create_new_fn successfully wrote to disk this session. Do NOT include names where create_new_fn errored.'), }), + // Round-trip the engineer's structured output against disk before + // returning it. Anything in `use` / `created` must actually be + // readable via `store.readFn` — otherwise the engineer is + // hallucinating and the programmer downstream would hit ENOENT when + // it tries to load the fn. Throwing here forces the prompt loop to + // re-prompt the engineer with the validation error so it can fix the + // arrays. + validate: (output, ctx) => { + const { use = [], created = [] } = output; + const missing: string[] = []; + for (const name of [...use, ...created]) { + try { ctx.store.readFn(name); } catch { missing.push(name); } + } + if (missing.length > 0) { + throw new Error( + `Your output references function(s) that are NOT on disk: ${missing.join(', ')}. ` + + `Either remove them from \`use\` / \`created\` or actually create them via create_new_fn first. ` + + `Do NOT report a function as created when create_new_fn raised an error.`, + ); + } + }, }); diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index 0c46484..955c79e 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -271,6 +271,26 @@ Saying *"I need an API key — please paste it"* is the wrong move. Saying *"I created \`vars.plaidSecret\`; populate it from your Plaid dashboard at https://… and I'll be ready"* is the right one. +## When the user asks for "a function that does X" + +Treat that as a request to create a REUSABLE function. The user wants +to invoke it later with different inputs — so any value the function +operates on must be a PARAMETER, not a hardcoded constant inside the +body. Examples: + +- "function that computes prime factors of a number" → + \`primeFactors(n: num): list\`. The number is a parameter; the + body reads it via \`get('n')\`. Do NOT bake a sample like 56 into + the body. +- "function that fetches a user from the API" → + \`fetchUser(id: text): User\`. +- "compute 2 + 2" → that's a one-shot question, not a function. Just + test/finish without saving. + +When you delegate to \`find_or_create_functions\`, spell out which +inputs are user-supplied (parameters) versus fixed in the description. +The engineer uses your description verbatim to design the signature. + ## Workflow 1. If the task needs types / fns / vars not in scope, call diff --git a/packages/ginny/src/store.ts b/packages/ginny/src/store.ts index 65a3558..3c00966 100644 --- a/packages/ginny/src/store.ts +++ b/packages/ginny/src/store.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import type { TypeDef, ExprDef } from '@aeye/gin'; +import type { TypeDef } from '@aeye/gin'; const THRESHOLD = parseInt(process.env['GIN_SEARCH_THRESHOLD'] ?? '20', 10); @@ -16,8 +16,15 @@ export interface Store { writeType(def: TypeDef): string; searchFns(q: { keywords: string[]; limit?: number }): SearchResult[]; - readFn(name: string): { type: TypeDef; body: ExprDef }; - writeFn(name: string, v: { type: TypeDef; body: ExprDef }): string; + /** + * Saved functions are stored as a single TypeDef (a `function`-typed + * `TypeDef` with the body in `call.get`). That's gin's native callable + * shape — see `gin/src/__tests__/recurse.test.ts:267` for the pattern. + * The path walker handles invocation, args binding, and recurse with + * no ginny-side wrapping. + */ + readFn(name: string): TypeDef; + writeFn(name: string, def: TypeDef): string; searchVars(q: { keywords: string[]; limit?: number }): SearchResult[]; readVar(name: string): { type: TypeDef; value: unknown; docs?: string }; diff --git a/packages/ginny/src/tools/find-or-create-fns.ts b/packages/ginny/src/tools/find-or-create-fns.ts index 056a8ca..7c0d94a 100644 --- a/packages/ginny/src/tools/find-or-create-fns.ts +++ b/packages/ginny/src/tools/find-or-create-fns.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ai } from '../ai'; import { runSubagent } from '../progress'; import { registerFnAsGlobal } from '../fns-global'; +import { MAX_PROGRAMMER_DEPTH } from '../context'; interface EngineerResult { use: string[]; @@ -15,6 +16,12 @@ export const findOrCreateFunctions = ai.tool({ schema: z.object({ description: z.string().describe('What functions are needed and why'), }), + // The engineer's `create_new_fn` recursively spawns another programmer. + // Past the depth cap, exposing this tool lets the agent loop forever + // (programmer → engineer → programmer → engineer → ...). Withholding + // it forces the deepest programmer to author the function inline via + // write/test/finish, which is what the user actually wants. + applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, call: async (input: { description: string }, _refs, ctx) => { const { engineer } = await import('../prompts/engineer'); const result = await runSubagent( @@ -25,7 +32,8 @@ export const findOrCreateFunctions = ai.tool({ if (!result) return 'Engineer returned no result.'; const { use = [], created = [] } = result; - const lines: string[] = []; + const loaded: string[] = []; + const ghosts: string[] = []; for (const name of [...use, ...created]) { if (!ctx.loadedFns.has(name)) { @@ -35,22 +43,39 @@ export const findOrCreateFunctions = ai.tool({ ctx.registry.register(type); // Wire as a runtime callable so programs can invoke it. // Without this the fn is only typed-known, not executable. - registerFnAsGlobal(ctx, name, type, def.body); + registerFnAsGlobal(ctx.engine, name, type, def.body); ctx.loadedFns.add(name); - } catch (e: unknown) { - lines.push(`// Could not load fn '${name}': ${e instanceof Error ? e.message : String(e)}`); + } catch { + // The engineer claimed this function exists but there's no + // file on disk. This happens when the engineer hallucinates a + // success in its structured output even though create_new_fn + // didn't actually write anything (e.g. inner programmer + // failed). Drop the ghost and surface it so the caller knows + // not to trust the engineer's claim. + ghosts.push(name); continue; } } try { const def = ctx.store.readFn(name); const type = ctx.registry.parse(def.type); - lines.push(`fn ${name}: ${type.toCode()}`); + loaded.push(`fn ${name}: ${type.toCode()}`); } catch { - lines.push(`fn ${name}`); + loaded.push(`fn ${name}`); } } - return lines.join('\n') || 'No functions loaded.'; + if (loaded.length === 0 && ghosts.length === 0) { + return 'No functions loaded.'; + } + const parts: string[] = []; + if (loaded.length > 0) parts.push(loaded.join('\n')); + if (ghosts.length > 0) { + parts.push( + `// Engineer claimed these were created but no file was written: ${ghosts.join(', ')}.\n` + + `// Treat them as NOT available — write your program inline or retry find_or_create_functions with a clearer description.`, + ); + } + return parts.join('\n\n'); }, }); diff --git a/packages/ginny/src/tools/finish.ts b/packages/ginny/src/tools/finish.ts index 33893ed..76140f1 100644 --- a/packages/ginny/src/tools/finish.ts +++ b/packages/ginny/src/tools/finish.ts @@ -50,18 +50,23 @@ export const finish = ai.tool({ if (input.saveAs) { const name = input.saveAs; const r = ctx.registry; - // Programs are nullary by default — wrap them as `fn({}, ResultType)` - // so they can be called as `name({})`. The engineer path is the - // place to author parameterized fns. - const argsType = r.obj({}); - const returnType = ctx.engine.typeOf(draft); + + // When the engineer set up this run via `create_new_fn`, the + // intended signature lives on `ctx.targetFn`. Use it so the saved + // type matches what the engineer designed instead of being + // inferred from the body — `engine.typeOf(draft)` of an if/elif + // chain lands on weird unions like `or`, which is + // useless to callers expecting `(n: num) => list`. + const useTarget = ctx.targetFn && ctx.targetFn.name === name; + const argsType = useTarget ? ctx.targetFn!.argsType : r.obj({}); + const returnType = useTarget ? ctx.targetFn!.returnsType : ctx.engine.typeOf(draft); const fnType = r.fn(argsType, returnType); try { ctx.store.writeFn(name, { type: fnType.toJSON(), body: draft }); // Register only as a runtime global — FnType.name is always // 'function', so calling registry.register(fnType) would clobber // the canonical FnType class, not create a named entry. - registerFnAsGlobal(ctx, name, fnType, draft); + registerFnAsGlobal(ctx.engine, name, fnType, draft); ctx.loadedFns.add(name); savedNote = ` (saved as fn '${name}': ${fnType.toCode()})`; } catch (e: unknown) { diff --git a/packages/ginny/src/tools/test.ts b/packages/ginny/src/tools/test.ts index a5a6702..cbfe479 100644 --- a/packages/ginny/src/tools/test.ts +++ b/packages/ginny/src/tools/test.ts @@ -1,16 +1,93 @@ import { z } from 'zod'; import { ToolInterrupt } from '@aeye/core'; +import { val, type Value, type ObjType, type Registry } from '@aeye/gin'; import { ai } from '../ai'; import { flushDirtyVars } from '../vars-global'; +/** + * Build the Zod sub-schema the model sees for `args`. + * + * - When the engineer is authoring a fn (`ctx.targetFn?.argsType` is + * set), use that obj type's value-side schema directly. The model + * sees `{ n: number, m: string }` instead of an opaque + * `Record` and stops trying to invent wrapper + * names like `obj` or `args` to read from scope. + * - Otherwise (top-level / generic case) programs rarely take + * external scope vars; keep a permissive record fallback so the + * tool still works for ad-hoc one-off uses. + */ +function buildArgsSchema(argsType: ObjType | undefined): z.ZodTypeAny { + if (argsType) { + return argsType.toValueSchema({ includeDocs: 'all' }).describe( + `Scope variables — keys ARE the function's parameter names. Each key becomes a scope variable the program reads via { kind: 'get', path: [{ prop: '' }] }. Do NOT wrap in another object.`, + ); + } + return z + .record(z.string(), z.unknown()) + .describe( + 'Scope variables — keys become variable names the program reads by name. NOT a single wrapper object; do NOT read `args` or `obj` from scope, read the names you put here.', + ); +} + +/** + * `engine.run`'s extras must be `Record` — the schema + * lets the model pass plain JSON, so we wrap on the way in. + * + * Gin's calling convention exposes a function's parameters as a single + * `args` scope variable, not as top-level scope entries (matches + * `Lambda.evaluate` in @aeye/gin/exprs/lambda.ts). Param names live + * under `args.*` so they can't collide with globals (`fns`, `vars`, + * loaded fns), `recurse`, or lambda context names (`this`, `super`, + * `key`, `value`). + * + * - With `argsType`, parse the entire args object through that obj + * type to get a typed `Value`, then bind it as `args`. + * - Without it (top-level / generic case) the model rarely passes + * args. Wrap as `val(any, raw)` for whatever shape it provided. + */ +function buildScopeExtras( + registry: Registry, + argsType: ObjType | undefined, + rawArgs: Record | undefined, +): Record { + if (argsType) { + try { + const parsed = argsType.parse(rawArgs ?? {}); + return { args: parsed }; + } catch { + // Parse failed — fall through to a permissive any-typed args. + } + } + if (!rawArgs) return {}; + return { args: val(registry.any(), rawArgs) }; +} + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message || err.name || 'Error'; + if (typeof err === 'string') return err; + if (err && typeof err === 'object') { + try { + const json = JSON.stringify(err); + // {} is the same uselessness as [object Object] — surface the + // constructor name as a last-resort hint. + if (json && json !== '{}') return json; + } catch { /* circular or unserializable */ } + const ctor = (err.constructor && err.constructor.name) || 'Object'; + return `<${ctor}>`; + } + return String(err); +} + export const test = ai.tool({ name: 'test', description: 'Execute the stored draft program and return the result.', - instructions: 'Run the draft. Set expectError=true if a runtime error is the expected outcome.', - schema: z.object({ - args: z.record(z.string(), z.unknown()).optional().describe('Extra scope variables'), - expectError: z.boolean().optional().describe('If true, a runtime error counts as success'), - }), + instructions: + 'Run the draft. `args` are scope variables the program reads by name — its schema reflects the function being authored when one is in scope, so just pass concrete values for each parameter. Set `expectError: true` if a runtime error is the expected outcome.', + schema: (ctx) => + z.object({ + args: buildArgsSchema(ctx.targetFn?.argsType).optional(), + expectError: z.boolean().optional().describe('If true, a runtime error counts as success'), + }) as unknown as z.ZodType<{ args?: Record; expectError?: boolean }>, applicable: (ctx) => !!ctx.runState.draft, call: async ( input: { args?: Record; expectError?: boolean }, @@ -22,7 +99,8 @@ export const test = ai.tool({ } try { - const value = await ctx.engine.run(ctx.runState.draft, input.args as any); + const scopeExtras = buildScopeExtras(ctx.registry, ctx.targetFn?.argsType, input.args); + const value = await ctx.engine.run(ctx.runState.draft, scopeExtras); const rawResult = value.type?.encode ? value.type.encode(value.raw) : value.raw; if (input.expectError) { @@ -39,7 +117,7 @@ export const test = ai.tool({ return `SUCCESS: ${JSON.stringify(rawResult)}${persistedNote}`; } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = formatError(err); if (input.expectError) { ctx.runState.lastTest = { success: true, error: errMsg, expectError: true }; return `SUCCESS (expected error): ${errMsg}`; diff --git a/packages/ginny/src/tools/write.ts b/packages/ginny/src/tools/write.ts index 9e24fda..d5a0139 100644 --- a/packages/ginny/src/tools/write.ts +++ b/packages/ginny/src/tools/write.ts @@ -2,11 +2,16 @@ import { z } from 'zod'; import { buildSchemas } from '@aeye/gin'; import type { ExprDef } from '@aeye/gin'; import { ai } from '../ai'; +import { logger } from '../logger'; export const write = ai.tool({ name: 'write', description: 'Write a gin program expression and store it as the draft.', - instructions: 'Store the program draft. Provide the gin ExprDef JSON as "program".', + instructions: + 'Store the program draft. Provide the gin ExprDef JSON as "program". ' + + 'Returns the program rendered as TypeScript-like source via toCode() so you can sanity-check what gin actually parsed, ' + + 'plus any validation problems (unknown vars / props / out-of-place flow / type mismatches) found by static analysis. ' + + 'Fix reported errors before calling test().', schema: (ctx) => { const opts = buildSchemas(ctx.registry, { newStrict: true }); return z.object({ program: opts.Expr as z.ZodType }); @@ -14,6 +19,49 @@ export const write = ai.tool({ call: async (input: { program: ExprDef }, _refs, ctx) => { ctx.runState.draft = input.program; ctx.runState.lastTest = null; - return 'Draft saved. Call test() to evaluate it.'; + + let code: string; + try { + code = ctx.engine.toCode(input.program); + } catch (e: unknown) { + // toCode shouldn't throw for valid ExprDefs, but if the parse + // path hits a malformed sub-tree we still want write() to + // succeed — surface the rendering error inline. + code = `// toCode failed: ${e instanceof Error ? e.message : String(e)}`; + } + + // Build the type-scope `engine.validate` walks against. Globals + // are always there; when the engineer is authoring a fn, bind the + // entire args obj as a single `args` scope var (matches gin's + // runtime calling convention — see `fns-global.ts`). The body + // accesses params via `args.`. + const scope = new Map(ctx.engine.globalTypeScope()); + if (ctx.targetFn) { + scope.set('args', ctx.targetFn.argsType); + } + + let problemsNote = ''; + try { + const problems = ctx.engine.validate(input.program, scope); + if (problems.list.length > 0) { + const lines = problems.list.map((p) => { + const path = p.path.length > 0 ? ` @ ${p.path.join('.')}` : ''; + return ` - [${p.severity}] ${p.code}: ${p.message}${path}`; + }); + problemsNote = `\n\n[validation problems — fix these before calling test()]\n${lines.join('\n')}`; + } + } catch (e: unknown) { + // validate shouldn't throw, but be defensive — a thrown error + // here shouldn't take down the write call. + problemsNote = `\n\n[validation threw: ${e instanceof Error ? e.message : String(e)}]`; + } + + // Mirror to stderr for the user watching the terminal, and to + // ginny.log for the post-mortem. + process.stderr.write(`\x1b[2m${code}\x1b[0m\n`); + if (problemsNote) process.stderr.write(`\x1b[31m${problemsNote.trim()}\x1b[0m\n`); + logger.log(`write:\n${code}${problemsNote}`); + + return `Draft saved. Call test() to evaluate it.\n\n${code}${problemsNote}`; }, }); From b4f84995e9e62adbfabcfe59513116ddb824a026 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Wed, 29 Apr 2026 13:27:48 -0400 Subject: [PATCH 02/21] toSchema enhancements --- .../gin/src/__tests__/binding-rules.test.ts | 248 ++++++++++++++++++ .../__tests__/define-type-inference.test.ts | 218 +++++++++++++++ .../gin/src/__tests__/expr-validate.test.ts | 8 +- packages/gin/src/analysis.ts | 36 +++ packages/gin/src/exprs/block.ts | 6 +- packages/gin/src/exprs/define.ts | 40 ++- packages/gin/src/exprs/flow.ts | 16 +- packages/gin/src/exprs/get.ts | 6 +- packages/gin/src/exprs/if.ts | 13 +- packages/gin/src/exprs/lambda.ts | 12 +- packages/gin/src/exprs/loop.ts | 47 +++- packages/gin/src/exprs/native.ts | 8 +- packages/gin/src/exprs/new.ts | 24 +- packages/gin/src/exprs/set.ts | 10 +- packages/gin/src/exprs/switch.ts | 20 +- packages/gin/src/exprs/template.ts | 8 +- packages/gin/src/schemas.ts | 34 ++- packages/gin/src/scope.ts | 23 +- packages/ginny/src/fns-global.ts | 38 --- packages/ginny/src/prompts/engineer.ts | 11 +- packages/ginny/src/store.ts | 11 +- .../ginny/src/tools/find-or-create-fns.ts | 26 +- packages/ginny/src/tools/finish.ts | 48 ++-- packages/ginny/src/tools/test.ts | 98 ++++--- packages/ginny/src/tools/write.ts | 15 +- 25 files changed, 844 insertions(+), 180 deletions(-) create mode 100644 packages/gin/src/__tests__/binding-rules.test.ts create mode 100644 packages/gin/src/__tests__/define-type-inference.test.ts delete mode 100644 packages/ginny/src/fns-global.ts diff --git a/packages/gin/src/__tests__/binding-rules.test.ts b/packages/gin/src/__tests__/binding-rules.test.ts new file mode 100644 index 0000000..c99aebf --- /dev/null +++ b/packages/gin/src/__tests__/binding-rules.test.ts @@ -0,0 +1,248 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, RESERVED_NAMES, checkBindingName, Problems } from '../index'; +import type { TypeScope } from '../analysis'; + +/** + * Tests for the user-binding hygiene rules added in `analysis.ts` + * `checkBindingName` and consumed by `DefineExpr.validateWalk` / + * `LoopExpr.validateWalk`. The rules are: + * + * - User-supplied binding names cannot be reserved (gin's runtime + * binds those — `args`, `recurse`, `this`, `super`, `key`, `value`, + * `yield`, `error`). + * - User-supplied binding names cannot already exist in scope — + * including outer-scope vars and globals. + */ + +const e = new Engine(createRegistry()); + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; + +const numType = { name: 'num' } as const; + +describe('RESERVED_NAMES set', () => { + test('contains every name gin runtime injects', () => { + for (const n of ['args', 'recurse', 'this', 'super', 'key', 'value', 'yield', 'error']) { + expect(RESERVED_NAMES.has(n)).toBe(true); + } + }); + + test('does not include arbitrary user names', () => { + expect(RESERVED_NAMES.has('foo')).toBe(false); + expect(RESERVED_NAMES.has('result')).toBe(false); + }); +}); + +describe('checkBindingName helper', () => { + test('reserved name → binding.reserved error', () => { + const p = new Problems(); + const scope: TypeScope = new Map(); + checkBindingName('args', scope, p); + expect(p.list).toHaveLength(1); + expect(p.list[0]!.code).toBe('binding.reserved'); + expect(p.list[0]!.severity).toBe('error'); + }); + + test('name in scope → binding.shadow error', () => { + const p = new Problems(); + const scope: TypeScope = new Map(); + scope.set('foo', e.registry.num()); + checkBindingName('foo', scope, p); + expect(p.list).toHaveLength(1); + expect(p.list[0]!.code).toBe('binding.shadow'); + expect(p.list[0]!.severity).toBe('error'); + }); + + test('reserved name takes precedence over shadow check', () => { + // A name that is BOTH reserved AND in scope reports as reserved + // (clearer message; the helper returns after the reserved branch). + const p = new Problems(); + const scope: TypeScope = new Map(); + scope.set('args', e.registry.any()); + checkBindingName('args', scope, p); + expect(p.list).toHaveLength(1); + expect(p.list[0]!.code).toBe('binding.reserved'); + }); + + test('fresh non-reserved name → no error', () => { + const p = new Problems(); + const scope: TypeScope = new Map(); + checkBindingName('myVar', scope, p); + expect(p.list).toHaveLength(0); + }); +}); + +describe('DefineExpr — reserved-name rule', () => { + for (const reserved of ['args', 'recurse', 'this', 'super', 'key', 'value', 'yield', 'error']) { + test(`define '${reserved}' → binding.reserved`, () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: reserved, type: numType, value: numLit(1) }], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code === 'binding.reserved')).toBe(true); + }); + } + + test('reserved-name error path includes vars[i].name', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'args', type: numType, value: numLit(1) }], + body: numLit(0), + }); + const err = probs.list.find((p) => p.code === 'binding.reserved'); + expect(err).toBeDefined(); + expect(err!.path).toEqual(['vars', 0, 'name']); + }); + + test('non-reserved name → no binding error', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'myCounter', type: numType, value: numLit(1) }], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code.startsWith('binding.'))).toBe(false); + }); +}); + +describe('DefineExpr — shadow rule', () => { + test('two vars in one define with the same name → second flags shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'x', type: numType, value: numLit(1) }, + { name: 'x', type: numType, value: numLit(2) }, + ], + body: numLit(0), + }); + const shadows = probs.list.filter((p) => p.code === 'binding.shadow'); + expect(shadows).toHaveLength(1); + expect(shadows[0]!.path).toEqual(['vars', 1, 'name']); + }); + + test('inner define shadowing outer define → flags shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', type: numType, value: numLit(1) }], + body: { + kind: 'define', + vars: [{ name: 'x', type: numType, value: numLit(2) }], + body: numLit(0), + }, + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(true); + }); + + test('define shadowing a global → flags shadow', () => { + // Register a global so its name is part of the engine's type scope. + const r = createRegistry(); + const eng = new Engine(r); + eng.registerGlobal('myGlobal', { type: r.num(), value: 42 }); + const probs = eng.validate({ + kind: 'define', + vars: [{ name: 'myGlobal', type: numType, value: numLit(1) }], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(true); + }); + + test('sibling defines with distinct names → no shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'a', type: numType, value: numLit(1) }, + { name: 'b', type: numType, value: numLit(2) }, + ], + body: numLit(0), + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(false); + }); +}); + +describe('DefineExpr — runtime still works for valid bindings', () => { + test('valid define evaluates to the body result', async () => { + const v = await e.run({ + kind: 'define', + vars: [{ name: 'x', type: numType, value: numLit(7) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(v.raw).toBe(7); + }); +}); + +describe('LoopExpr — overrides honor binding rules', () => { + // Build a list over expression so the loop's `over` typechecks. + const overList = { + kind: 'new', + type: { name: 'list', generic: { V: { name: 'num' } } }, + value: [ + { kind: 'new', type: { name: 'num' }, value: 10 }, + { kind: 'new', type: { name: 'num' }, value: 20 }, + ], + } as const; + + test('keyName override to a reserved name → binding.reserved at path "key"', () => { + const probs = e.validate({ + kind: 'loop', + over: overList, + key: 'args', + body: { kind: 'block', lines: [] }, + }); + const err = probs.list.find((p) => p.code === 'binding.reserved'); + expect(err).toBeDefined(); + expect(err!.path).toEqual(['key']); + }); + + test('valueName override to a reserved name → binding.reserved at path "value"', () => { + const probs = e.validate({ + kind: 'loop', + over: overList, + value: 'recurse', + body: { kind: 'block', lines: [] }, + }); + const err = probs.list.find((p) => p.code === 'binding.reserved'); + expect(err).toBeDefined(); + expect(err!.path).toEqual(['value']); + }); + + test('keyName override that shadows an outer binding → binding.shadow', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'taken', type: numType, value: numLit(0) }], + body: { + kind: 'loop', + over: overList, + key: 'taken', + body: { kind: 'block', lines: [] }, + }, + }); + expect(probs.list.some((p) => p.code === 'binding.shadow')).toBe(true); + }); + + test('default key/value (no override) → no binding error even if `key` exists in outer scope', () => { + // Defaults are reserved precisely because loops bind them. Nested + // loops are expected to shadow `key`/`value`; we only validate the + // explicit overrides. + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'someName', type: numType, value: numLit(0) }], + body: { + kind: 'loop', + over: overList, + body: { kind: 'block', lines: [] }, + }, + }); + expect(probs.list.some((p) => p.code.startsWith('binding.'))).toBe(false); + }); + + test('valid keyName/valueName override → no error', () => { + const probs = e.validate({ + kind: 'loop', + over: overList, + key: 'idx', + value: 'item', + body: { kind: 'block', lines: [] }, + }); + expect(probs.list.some((p) => p.code.startsWith('binding.'))).toBe(false); + }); +}); diff --git a/packages/gin/src/__tests__/define-type-inference.test.ts b/packages/gin/src/__tests__/define-type-inference.test.ts new file mode 100644 index 0000000..cc67f37 --- /dev/null +++ b/packages/gin/src/__tests__/define-type-inference.test.ts @@ -0,0 +1,218 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, ListType } from '../index'; +import type { DefineExprDef } from '../index'; + +/** + * `DefineExpr` lets callers omit `type` per-var; the type is inferred + * from the value's static type (`new` carries its type, `get` walks + * the path to a target type, `if`/`block` infers from the branches, + * etc.). These tests pin that behavior down end-to-end: + * + * - Inference: `typeOf` of a typeless var matches the value's typeOf. + * - Chaining: `vars[i].value` may reference any earlier var by name — + * runtime, typeOf, AND validateWalk all see the updated scope. + * - Round-trip: omitting `type` survives `toJSON()` (no spurious + * `type: undefined` in the serialized form). + * - Mismatch is an error severity, not a warning, when an explicit + * type contradicts the value's inferred type. + */ + +const e = new Engine(createRegistry()); +const r = e.registry; + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; +const txtLit = (s: string) => ({ kind: 'new', type: { name: 'text' }, value: s }) as const; + +describe('Define — type is optional and inferred from the value', () => { + test('runtime: typeless var binds the value just fine', async () => { + const v = await e.run({ + kind: 'define', + vars: [{ name: 'x', value: numLit(42) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(v.raw).toBe(42); + }); + + test('typeOf: omitted type is inferred from the value', () => { + const t = e.typeOf({ + kind: 'define', + vars: [{ name: 'x', value: numLit(7) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(t.name).toBe('num'); + }); + + test('typeOf: list value yields a list type with the right element', () => { + const t = e.typeOf({ + kind: 'define', + vars: [{ + name: 'xs', + value: { + kind: 'new', + type: { name: 'list', generic: { V: { name: 'num' } } }, + value: [numLit(1), numLit(2)], + }, + }], + body: { kind: 'get', path: [{ prop: 'xs' }] }, + }); + expect(t.name).toBe('list'); + expect(t).toBeInstanceOf(ListType); + expect((t as ListType).item.name).toBe('num'); + }); + + test('validate: typeless var produces no problems on a clean program', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(probs.list).toHaveLength(0); + }); +}); + +describe('Define — chaining: each var sees previous vars', () => { + test('runtime: var2 reads var1 by name', async () => { + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(10) }, + { name: 'y', value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(v.raw).toBe(10); + }); + + test('runtime: var3 can chain through var2 → var1', async () => { + const v = await e.run({ + kind: 'define', + vars: [ + { name: 'a', value: numLit(2) }, + { + name: 'b', + value: { + kind: 'get', + path: [ + { prop: 'a' }, { prop: 'add' }, + { args: { other: numLit(3) } }, + ], + }, + }, + { + name: 'c', + value: { + kind: 'get', + path: [ + { prop: 'b' }, { prop: 'mul' }, + { args: { other: numLit(2) } }, + ], + }, + }, + ], + body: { kind: 'get', path: [{ prop: 'c' }] }, + }); + expect(v.raw).toBe(10); // (2 + 3) * 2 + }); + + test('typeOf: later var inherits the type of the earlier it references', () => { + const t = e.typeOf({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(1) }, + { name: 'y', value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(t.name).toBe('num'); + }); + + test('validate: walking var2.value uses the updated scope so var1 is known', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(1) }, + // If validateWalk used the parent scope, this `get` would + // produce a `var.unknown` problem for `x`. It mustn't. + { name: 'y', value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(probs.list.some((p) => p.code === 'var.unknown')).toBe(false); + expect(probs.list).toHaveLength(0); + }); + + test('validate: var3 referencing var1 through var2 path still resolves', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'a', value: numLit(2) }, + { name: 'b', value: { kind: 'get', path: [{ prop: 'a' }] } }, + { name: 'c', value: { kind: 'get', path: [{ prop: 'b' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'c' }] }, + }); + expect(probs.list).toHaveLength(0); + }); +}); + +describe('Define — explicit type still works alongside inference', () => { + test('explicit type matching value → ok', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', type: { name: 'num' }, value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + expect(probs.list).toHaveLength(0); + }); + + test('explicit type mismatching value → error severity', () => { + const probs = e.validate({ + kind: 'define', + vars: [{ name: 'x', type: { name: 'num' }, value: txtLit('nope') }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }); + const mm = probs.list.find((p) => p.code === 'define.var.type-mismatch'); + expect(mm).toBeDefined(); + expect(mm!.severity).toBe('error'); + expect(mm!.path).toEqual(['vars', 0, 'value']); + }); + + test('chained vars: explicit type on var2 mismatching var1.type → error', () => { + const probs = e.validate({ + kind: 'define', + vars: [ + { name: 'x', value: numLit(5) }, + // var2 declares text but the value reads var1 (num). Should + // be a type-mismatch error, not a silently-passing program. + { name: 'y', type: { name: 'text' }, value: { kind: 'get', path: [{ prop: 'x' }] } }, + ], + body: { kind: 'get', path: [{ prop: 'y' }] }, + }); + expect(probs.list.some((p) => p.code === 'define.var.type-mismatch' && p.severity === 'error')).toBe(true); + }); +}); + +describe('Define — JSON round-trip preserves omitted type', () => { + test('toJSON does not emit a type field when none was set', () => { + const def: DefineExprDef = { + kind: 'define', + vars: [{ name: 'x', value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }; + const expr = r.parseExpr(def); + const back = expr.toJSON() as DefineExprDef; + expect(back.vars[0]).toEqual({ name: 'x', value: numLit(1) }); + expect('type' in back.vars[0]!).toBe(false); + }); + + test('toJSON preserves a type field when it was set', () => { + const def: DefineExprDef = { + kind: 'define', + vars: [{ name: 'x', type: { name: 'num' }, value: numLit(1) }], + body: { kind: 'get', path: [{ prop: 'x' }] }, + }; + const expr = r.parseExpr(def); + const back = expr.toJSON() as DefineExprDef; + expect(back.vars[0]!.type).toEqual({ name: 'num' }); + }); +}); diff --git a/packages/gin/src/__tests__/expr-validate.test.ts b/packages/gin/src/__tests__/expr-validate.test.ts index d7b6301..9701f05 100644 --- a/packages/gin/src/__tests__/expr-validate.test.ts +++ b/packages/gin/src/__tests__/expr-validate.test.ts @@ -149,7 +149,7 @@ describe('SetExpr validation', () => { }); describe('DefineExpr validation', () => { - test('declared var type incompatible with value → warn', () => { + test('declared var type incompatible with value → error', () => { const probs = e.validate({ kind: 'define', vars: [{ @@ -159,10 +159,12 @@ describe('DefineExpr validation', () => { }], body: { kind: 'get', path: [{ prop: 'x' }] }, }); - expect(probs.list.some((p) => p.code === 'define.var.type-mismatch')).toBe(true); + const mismatch = probs.list.find((p) => p.code === 'define.var.type-mismatch'); + expect(mismatch).toBeDefined(); + expect(mismatch!.severity).toBe('error'); }); - test('declared var type matches value → no warn', () => { + test('declared var type matches value → no error', () => { const probs = e.validate({ kind: 'define', vars: [{ diff --git a/packages/gin/src/analysis.ts b/packages/gin/src/analysis.ts index a363a7b..25ea7bf 100644 --- a/packages/gin/src/analysis.ts +++ b/packages/gin/src/analysis.ts @@ -3,6 +3,7 @@ import type { Type } from './type'; import type { ExprDef } from './schema'; import { Problems } from './problem'; import { Expr, type ValidateContext } from './expr'; +import { RESERVED_NAMES } from './scope'; /** * Static type scope: name → runtime Type. Used by typeOf / validate to @@ -56,3 +57,38 @@ export function walkValidate( // Re-export ValidateContext for convenience. export type { ValidateContext } from './expr'; + +/** + * Validate a user-supplied binding name against the rules a `define` + * (or any other user-named scope binding) must follow: + * + * 1. Must not be a reserved name — gin's runtime injects those at + * well-known contexts (`args`, `recurse`, etc.); a user binding + * would be silently shadowed at runtime. + * 2. Must not already exist in `scope` — including names from outer + * scopes / globals. Disallowing this prevents accidental shadowing + * that produces confusing-at-runtime behavior (e.g. `define vars = + * ...` shadowing the persistent vars global). + * + * Pushes errors into `p`; never throws. Caller is expected to have + * already entered the relevant `at(...)` path. + */ +export function checkBindingName( + name: string, + scope: TypeScope, + p: Problems, +): void { + if (RESERVED_NAMES.has(name)) { + p.error( + 'binding.reserved', + `'${name}' is a reserved name (gin binds it automatically in fn/loop/path contexts) — pick a different name`, + ); + return; + } + if (scope.has(name)) { + p.error( + 'binding.shadow', + `'${name}' is already in scope — pick a different name to avoid shadowing`, + ); + } +} diff --git a/packages/gin/src/exprs/block.ts b/packages/gin/src/exprs/block.ts index 349e549..e11cdcf 100644 --- a/packages/gin/src/exprs/block.ts +++ b/packages/gin/src/exprs/block.ts @@ -32,7 +32,11 @@ export class BlockExpr extends Expr { return z.object({ kind: z.literal('block'), ...baseExprFields, - lines: z.array(opts.Expr), + lines: z + .array(opts.Expr) + .describe( + 'Sequence of expressions evaluated in order. The block\'s value is the LAST line\'s value (an empty block returns void). Earlier lines run for their side effects (set, fns.fetch, etc.).', + ), }).meta({ aid: 'Expr_block' }); } diff --git a/packages/gin/src/exprs/define.ts b/packages/gin/src/exprs/define.ts index 6d6f098..dd548ca 100644 --- a/packages/gin/src/exprs/define.ts +++ b/packages/gin/src/exprs/define.ts @@ -5,7 +5,7 @@ import type { Value } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; import type { TypeScope } from '../analysis'; -import { typeOf, walkValidate } from '../analysis'; +import { checkBindingName, typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; @@ -45,11 +45,21 @@ export class DefineExpr extends Expr { return z.object({ kind: z.literal('define'), ...baseExprFields, - vars: z.array(z.object({ - name: z.string(), - type: opts.Type.optional(), - value: opts.Expr, - })), + vars: z + .array( + z.object({ + name: z.string().describe( + 'Variable name. Must NOT be a reserved name (args, recurse, this, super, key, value, yield, error) and must NOT shadow anything already in scope.', + ), + type: opts.Type.optional().describe( + 'Optional declared type. OMIT this field when the value already determines the type — every value Expr (`new`, `get`, `if`, ...) is typed, and the var inherits that type. Set this only when you need to widen / narrow / annotate beyond what the value alone produces; a mismatch with the value\'s inferred type is reported as `define.var.type-mismatch`.', + ), + value: opts.Expr.describe( + "The expression whose result is bound under `name`. May reference any earlier var in this define — each var is added to scope before the next var's value is evaluated.", + ), + }), + ) + .describe('Bindings introduced before `body`. Evaluated sequentially, so `vars[i].value` may reference any of `vars[0..i-1]`.'), body: opts.Expr, }).meta({ aid: 'Expr_define' }); } @@ -76,12 +86,24 @@ export class DefineExpr extends Expr { const child: TypeScope = new Map(scope); for (let i = 0; i < this.vars.length; i++) { const v = this.vars[i]!; + // Each var name must not be a reserved name and must not collide + // with anything already in scope (including earlier vars in this + // same define — those have been added to `child` by now). + p.at(['vars', i, 'name'], () => checkBindingName(v.name, child, p)); + // Walk the value against `child` (not the parent `scope`), so + // `vars[i].value` can read `vars[0..i-1]` by name. This is the + // "later vars can reference earlier" semantic — runtime + // (`evaluate`) and inference (`typeOf`) match. const valueT = p.at(['vars', i, 'value'], () => walkValidate(engine, v.value, child, p, ctx)); - // When a declared type is present, the value's inferred type must be - // assignable to it. + // When a declared type is present, the value's inferred type must + // be assignable to it. This is a real bug, not a style note — + // report as error so the model fixes the mismatch instead of + // ignoring a warning. (`type` is optional precisely so callers + // CAN omit it; the only reason to set it is to constrain the + // value, so a mismatch is always wrong.) if (v.type && !v.type.compatible(valueT)) { - p.at(['vars', i, 'value'], () => p.warn('define.var.type-mismatch', + p.at(['vars', i, 'value'], () => p.error('define.var.type-mismatch', `var '${v.name}' value type '${valueT.name}' not compatible with declared '${v.type!.name}'`)); } child.set(v.name, v.type ?? valueT); diff --git a/packages/gin/src/exprs/flow.ts b/packages/gin/src/exprs/flow.ts index ab45cf0..f4940a8 100644 --- a/packages/gin/src/exprs/flow.ts +++ b/packages/gin/src/exprs/flow.ts @@ -42,9 +42,19 @@ export class FlowExpr extends Expr { return z.object({ kind: z.literal('flow'), ...baseExprFields, - action: z.enum(['break', 'continue', 'return', 'exit', 'throw']), - value: opts.Expr.optional(), - error: opts.Expr.optional(), + action: z.enum(['break', 'continue', 'return', 'exit', 'throw']).describe( + 'Which control-flow signal to raise. ' + + '`break`/`continue` only valid inside a loop. ' + + '`return` only valid inside a fn body / lambda; unwinds to the enclosing call with `value`. ' + + '`exit` unwinds all the way to `engine.run`, returning `value` as the program result. ' + + '`throw` raises `error` (caught by a path step\'s `catch:` handler).', + ), + value: opts.Expr.optional().describe( + 'Required for `return` and `exit` (the value being returned). Ignored by `break` / `continue` / `throw`.', + ), + error: opts.Expr.optional().describe( + 'Required for `throw` — the value to raise. Ignored otherwise.', + ), }).meta({ aid: 'Expr_flow' }); } diff --git a/packages/gin/src/exprs/get.ts b/packages/gin/src/exprs/get.ts index 2d5177b..72e473e 100644 --- a/packages/gin/src/exprs/get.ts +++ b/packages/gin/src/exprs/get.ts @@ -32,7 +32,11 @@ export class GetExpr extends Expr { return z.object({ kind: z.literal('get'), ...baseExprFields, - path: z.array(pathStepSchema(opts)), + path: z + .array(pathStepSchema(opts)) + .describe( + 'Steps walked left-to-right starting from a scope variable. Step shapes: `{prop:"name"}` for prop/method access, `{args:{…}}` to call the previous step, `{key:Expr}` for index access. The first step MUST be a prop step (the scope-var name). Result is the final step\'s value.', + ), }).meta({ aid: 'Expr_get' }); } diff --git a/packages/gin/src/exprs/if.ts b/packages/gin/src/exprs/if.ts index 5d84cd1..83236c2 100644 --- a/packages/gin/src/exprs/if.ts +++ b/packages/gin/src/exprs/if.ts @@ -42,8 +42,17 @@ export class IfExpr extends Expr { return z.object({ kind: z.literal('if'), ...baseExprFields, - ifs: z.array(z.object({ condition: opts.Expr, body: opts.Expr })), - else: opts.Expr.optional(), + ifs: z + .array(z.object({ + condition: opts.Expr.describe('Bool-typed expression. First branch whose condition is `true` wins; the rest are skipped.'), + body: opts.Expr.describe('Evaluated when this branch\'s condition is true. The if-expression\'s value is this body\'s value.'), + })) + .describe( + 'Ordered list of `{condition, body}` branches — first true condition wins. Each `condition` must be bool-typed (warned otherwise). With multiple branches this is the gin equivalent of `if / else if / else if`.', + ), + else: opts.Expr.optional().describe( + 'Optional fallback evaluated when every `ifs[i].condition` is false. Without an else, a no-match if-expression evaluates to void.', + ), }).meta({ aid: 'Expr_if' }); } diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index d122516..421698c 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -45,9 +45,15 @@ export class LambdaExpr extends Expr { return z.object({ kind: z.literal('lambda'), ...baseExprFields, - type: opts.Type, - body: opts.Expr, - constraint: opts.Expr.optional(), + type: opts.Type.describe( + 'The lambda\'s function type — `{ name: "function", call: { args, returns } }` (or a registered named fn type). The `args` obj defines what the body sees under the `args` scope variable; `returns` is what the body must produce.', + ), + body: opts.Expr.describe( + 'The lambda body. At runtime, scope contains the lexical scope at definition site PLUS `args` (the call arguments) and `recurse` (this same lambda, for self-calls). Read params via `[{prop:"args"},{prop:""}]`.', + ), + constraint: opts.Expr.optional().describe( + 'Optional bool-typed precondition evaluated before the body on every call (with `args` in scope). If it returns false, the call throws. Use for input invariants you want enforced regardless of caller.', + ), }).meta({ aid: 'Expr_lambda' }); } diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 8315941..04357cc 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -6,7 +6,7 @@ import { BreakSignal, ContinueSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; import type { TypeScope } from '../analysis'; -import { walkValidate } from '../analysis'; +import { checkBindingName, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; @@ -54,14 +54,31 @@ export class LoopExpr extends Expr { return z.object({ kind: z.literal('loop'), ...baseExprFields, - over: opts.Expr, - body: opts.Expr, - key: z.string().optional(), - value: z.string().optional(), - parallel: z.object({ - concurrent: opts.Expr.optional(), - rate: opts.Expr.optional(), - }).optional(), + over: opts.Expr.describe( + 'The iterable to walk — must evaluate to a value whose type defines `get().loop` (lists by index, maps by key, etc.). NOT a bool: gin has no while-loop; a finite iterable is required.', + ), + body: opts.Expr.describe( + "Evaluated once per iteration with the current `key` and `value` bound in scope. Use `{kind:'flow', action:'break'}` or `'continue'` for early-exit. The loop expression itself returns void.", + ), + key: z.string().optional().describe( + 'Override the scope-variable name the iteration index/key is bound under (default: `key`). Must NOT be reserved or shadow an outer scope var. Use to disambiguate when looping inside another loop.', + ), + value: z.string().optional().describe( + 'Override the scope-variable name the iteration value is bound under (default: `value`). Same rules as `key`.', + ), + parallel: z + .object({ + concurrent: opts.Expr.optional().describe( + 'Max in-flight iterations as a num (omit / 1 → strictly sequential). Use when iterations are independent I/O — e.g. fetching N URLs concurrently.', + ), + rate: opts.Expr.optional().describe( + 'Minimum interval between iteration starts. Accepts a num (milliseconds) or a duration. Use to rate-limit fan-out (e.g. avoid hammering an API).', + ), + }) + .optional() + .describe( + 'Opt-in parallelism. Both fields are optional and independent: `concurrent` caps fan-out width, `rate` paces start times. Iterations may finish out of order; the body should not assume sequential ordering.', + ), }).meta({ aid: 'Expr_loop' }); } @@ -167,6 +184,18 @@ export class LoopExpr extends Expr { } } + // If the loop overrides keyName / valueName, the user-chosen names + // must follow the same rules as define vars: not reserved, not + // already in scope. The default `key` / `value` names are reserved + // by gin precisely because loops bind them, so we don't check the + // defaults — only explicit overrides. + if (this.keyName !== undefined) { + p.at('key', () => checkBindingName(this.keyName!, scope, p)); + } + if (this.valueName !== undefined) { + p.at('value', () => checkBindingName(this.valueName!, scope, p)); + } + // Bind key/value using the iterable's actual types (not any) so the // body validates against correct inner types. Fall back to any only // when the iterable surface was missing (already errored above). diff --git a/packages/gin/src/exprs/native.ts b/packages/gin/src/exprs/native.ts index 0d1f403..29a5b7f 100644 --- a/packages/gin/src/exprs/native.ts +++ b/packages/gin/src/exprs/native.ts @@ -31,8 +31,12 @@ export class NativeExpr extends Expr { return z.object({ kind: z.literal('native'), ...baseExprFields, - id: z.string(), - type: opts.Type.optional(), + id: z.string().describe( + 'Identifier of a native impl registered via `registry.setNative(id, fn)` (e.g. `list.push`, `num.add`). The model should NOT generate `native` expressions directly — methods on built-in types are reached via `get` paths, which gin resolves to natives internally.', + ), + type: opts.Type.optional().describe( + 'Optional type to wrap the native\'s raw return value with when the impl returns a non-Value. Defaults to `any` if omitted.', + ), }).meta({ aid: 'Expr_native' }); } diff --git a/packages/gin/src/exprs/new.ts b/packages/gin/src/exprs/new.ts index e15332b..35b3630 100644 --- a/packages/gin/src/exprs/new.ts +++ b/packages/gin/src/exprs/new.ts @@ -65,8 +65,12 @@ export class NewExpr extends Expr { z.object({ kind: z.literal('new'), ...baseExprFields, - type: z.object({ name: z.literal(t.name) }).passthrough(), - value: t.toNewSchema(opts).optional(), + type: z.object({ name: z.literal(t.name) }).passthrough().describe( + `Reference to the registered named type \`${t.name}\` — name-only, the registry resolves it to its full definition.`, + ), + value: t.toNewSchema(opts).optional().describe( + `Initial value for the new \`${t.name}\` instance. Each composite slot accepts an Expr (Get, NewExpr, etc.); per-slot type correctness is enforced at runtime.`, + ), }).meta({ aid: `New_${t.name}` }), ); // Per-built-in-class branches — full TypeDef shape + the class's @@ -79,8 +83,12 @@ export class NewExpr extends Expr { z.object({ kind: z.literal('new'), ...baseExprFields, - type: cls.toSchema(opts), - value: cls.toNewSchema(opts).optional(), + type: cls.toSchema(opts).describe( + `Full TypeDef for a \`${cls.NAME}\` instance (name + options + per-class fields).`, + ), + value: cls.toNewSchema(opts).optional().describe( + `Initial value matching this \`${cls.NAME}\` instance. Composites accept Expr slots; primitives accept their raw form.`, + ), }).meta({ aid: `New_${cls.NAME}` }), ); const all = [...instanceBranches, ...classBranches]; @@ -92,8 +100,12 @@ export class NewExpr extends Expr { return z.object({ kind: z.literal('new'), ...baseExprFields, - type: opts.Type, - value: z.any().optional(), + type: opts.Type.describe( + 'TypeDef of the value being constructed. The `value` field is interpreted relative to this type — primitives take their raw form (`new num` → number), composites take Expr slots (`new list` → Expr[]).', + ), + value: z.any().optional().describe( + 'Initial value matching `type`. Optional when the type has a defined `init` constructor or a sensible default (empty list, zero num with no constraints, etc.).', + ), }).meta({ aid: 'Expr_new' }); } diff --git a/packages/gin/src/exprs/set.ts b/packages/gin/src/exprs/set.ts index 649c399..8d8e372 100644 --- a/packages/gin/src/exprs/set.ts +++ b/packages/gin/src/exprs/set.ts @@ -34,8 +34,14 @@ export class SetExpr extends Expr { return z.object({ kind: z.literal('set'), ...baseExprFields, - path: z.array(pathStepSchema(opts)), - value: opts.Expr, + path: z + .array(pathStepSchema(opts)) + .describe( + 'Steps walked left-to-right to a writable target. Single-step `[{prop:"x"}]` re-assigns scope variable `x`. Multi-step targets need the type to support set on the final step (a prop with a `set` ExprDef, an indexed slot, or a method whose call has `set:`).', + ), + value: opts.Expr.describe( + 'The expression evaluated and assigned to the path target. Its type must be compatible with the target\'s declared type — checked statically as `set.type-mismatch`.', + ), }).meta({ aid: 'Expr_set' }); } diff --git a/packages/gin/src/exprs/switch.ts b/packages/gin/src/exprs/switch.ts index dd5a5fc..f1aab20 100644 --- a/packages/gin/src/exprs/switch.ts +++ b/packages/gin/src/exprs/switch.ts @@ -49,12 +49,20 @@ export class SwitchExpr extends Expr { return z.object({ kind: z.literal('switch'), ...baseExprFields, - value: opts.Expr, - cases: z.array(z.object({ - equals: z.array(opts.Expr), - body: opts.Expr, - })), - else: opts.Expr.optional(), + value: opts.Expr.describe( + 'Expression whose result is compared against each case\'s `equals` candidates. Evaluated once.', + ), + cases: z + .array(z.object({ + equals: z.array(opts.Expr).describe( + 'Candidate values for this case. The case wins if `value` equals ANY one of them (logical OR). Each candidate\'s type must be compatible with `value`\'s type — checked as `switch.case.type`.', + ), + body: opts.Expr.describe('Evaluated when this case wins. The switch expression\'s value is this body\'s value.'), + })) + .describe('Ordered list of cases — first match wins. Cases are NOT fall-through; only the matching case\'s body runs.'), + else: opts.Expr.optional().describe( + 'Optional fallback evaluated when no case matches. Without an else, a no-match switch evaluates to void.', + ), }).meta({ aid: 'Expr_switch' }); } diff --git a/packages/gin/src/exprs/template.ts b/packages/gin/src/exprs/template.ts index 30ec07f..21e1171 100644 --- a/packages/gin/src/exprs/template.ts +++ b/packages/gin/src/exprs/template.ts @@ -43,8 +43,12 @@ export class TemplateExpr extends Expr { return z.object({ kind: z.literal('template'), ...baseExprFields, - template: z.union([opts.Expr, z.string()]), - params: opts.Expr, + template: z.union([opts.Expr, z.string()]).describe( + 'The template string — either a literal string (auto-wrapped as `new text`) or an Expr that evaluates to text. Placeholders use `{name}` syntax; each `name` must appear as a key on `params`.', + ), + params: opts.Expr.describe( + 'Expression evaluating to an obj whose props supply the placeholder values. Each `{name}` in the template is replaced with the stringified value of `params.name`.', + ), }).meta({ aid: 'Expr_template' }); } diff --git a/packages/gin/src/schemas.ts b/packages/gin/src/schemas.ts index b22916e..07ba212 100644 --- a/packages/gin/src/schemas.ts +++ b/packages/gin/src/schemas.ts @@ -17,7 +17,13 @@ export type { SchemaOptions } from './node'; /** Shared ExprDef fields (just `comment`). */ export const baseExprFields: z.ZodRawShape = { - comment: z.string().optional().meta({ aid: 'Comment' }), + comment: z + .string() + .optional() + .describe( + 'Optional one-line note explaining why this expression exists. Travels with the node, surfaces in `toCode` as a `/* … */` annotation, and shows up in error paths. Use for non-obvious steps; skip for trivial reads.', + ) + .meta({ aid: 'Comment' }), }; /** Shared generic-parameter map (`{ [name]: Type }`). Used inline by @@ -30,13 +36,27 @@ export function genericSchema(opts: SchemaOptions): z.ZodTypeAny { /** Shared PathStep union used by GetExpr/SetExpr. */ export function pathStepSchema(opts: SchemaOptions): z.ZodTypeAny { return z.union([ - z.object({ prop: z.string() }), z.object({ - args: z.record(z.string(), opts.Expr), - generic: genericSchema(opts).optional(), - catch: opts.Expr.optional(), - }), - z.object({ key: opts.Expr }), + prop: z.string().describe( + "Named-property access. First step looks up a scope variable by name; subsequent steps read the previous value's prop / method. Reject if the name isn't on the type's `props()`.", + ), + }).describe('PROP step — `.` access (scope var on first step, prop/method on later steps).'), + z.object({ + args: z.record(z.string(), opts.Expr).describe( + 'Map of arg-name → ExprDef. Calls the previous step (a method or any callable). Each arg expression is evaluated in the caller scope before the call; the result obj is bound as `args` inside the call body.', + ), + generic: genericSchema(opts).optional().describe( + 'Optional generic-parameter map for parameterized callables (e.g. `list.map` binds `R` to the element type of the result list). Usually unnecessary — most callables infer generics.', + ), + catch: opts.Expr.optional().describe( + 'Optional handler expression evaluated if this call throws. The thrown value is bound under `error` in the handler scope.', + ), + }).describe('CALL step — invoke the previous step. Comes after a method (e.g. `list.push`) or any callable value.'), + z.object({ + key: opts.Expr.describe( + 'Indexed-access key expression. Evaluated at run time and passed to the previous value\'s `[key]` get/set surface.', + ), + }).describe('INDEX step — `[]` access for types with index signatures (lists by `num`, maps by their key type).'), ]).meta({ aid: 'PathStep' }); } diff --git a/packages/gin/src/scope.ts b/packages/gin/src/scope.ts index 297c823..7d06f19 100644 --- a/packages/gin/src/scope.ts +++ b/packages/gin/src/scope.ts @@ -1,11 +1,30 @@ import type { Value } from './value'; +/** + * Names gin's runtime injects into child scopes for specific + * expression contexts. User-authored bindings (`DefineExpr.vars[].name`, + * `LoopExpr.keyName`/`valueName` overrides) MUST NOT use these — the + * engine will rebind them at the relevant context, silently shadowing + * the user's value and producing very confusing behavior. + * + * - `args` — function parameters (Lambda, path call.get/set, NewExpr init). + * - `recurse` — self-reference in fn bodies. + * - `this` — receiver in prop/method bodies, NewExpr init, loop.over. + * - `super` — base impl in prop/method overrides. + * - `key`, `value` — loop iteration bindings (default names). + * - `yield` — internal yield callback for loop bodies. + * - `error` — bound in path catch handlers. + */ +export const RESERVED_NAMES: ReadonlySet = new Set([ + 'args', 'recurse', 'this', 'super', 'key', 'value', 'yield', 'error', +]); + /** * Scope: lexical variable bindings with parent chain. * * Root scope contains globals. Each Define/Lambda/Loop creates a child. - * Reserved names (this, args, result, key, value, yield, super) are - * injected per-context, not by globals. + * Reserved names (see `RESERVED_NAMES`) are injected per-context, not + * by globals. */ export class Scope { readonly parent: Scope | null; diff --git a/packages/ginny/src/fns-global.ts b/packages/ginny/src/fns-global.ts deleted file mode 100644 index 5212232..0000000 --- a/packages/ginny/src/fns-global.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Engine, ExprDef, Type, Value } from '@aeye/gin'; - -/** - * Wire a saved gin function (`fns/.json`) into the engine as a - * runtime callable global. With this in place a program can call - * `({...args})` directly — same calling convention as the - * built-in globals (`fns.fetch`, `fns.llm`). - * - * The body is evaluated lazily on each invocation: each call constructs - * a fresh root scope with the caller's args bound, so saved fns can be - * recursive, can reference other globals (`vars.*`, other saved fns), - * and stay decoupled from the parent program's scope. - * - * Takes `engine` directly rather than the dynamic `ctx` so this helper - * doesn't need to import the (purposely loose) ctx type. - */ -export function registerFnAsGlobal( - engine: Engine, - name: string, - type: Type, - body: ExprDef, -): void { - const callable = async (argsValue: Value): Promise => { - // Gin's calling convention: a function's parameters are bound as a - // single `args` scope var (matches how `Lambda.evaluate` does it - // in @aeye/gin/exprs/lambda.ts). The body accesses individual - // params via `[{prop: 'args'}, {prop: ''}]`. - // - // The single-namespace wrapping is deliberate: param names are - // controlled by the caller (engineer/programmer), but a function - // body also has globals (`fns`, `vars`, loaded fns), `recurse`, - // and lambda-context names (`this`, `super`, `key`, `value`) in - // scope. Putting params under `args.*` keeps any of those names - // free for use as parameters without collision risk. - return await engine.run(body, { args: argsValue }); - }; - engine.registerGlobal(name, { type, value: callable }); -} diff --git a/packages/ginny/src/prompts/engineer.ts b/packages/ginny/src/prompts/engineer.ts index 0a7dc39..c62bca5 100644 --- a/packages/ginny/src/prompts/engineer.ts +++ b/packages/ginny/src/prompts/engineer.ts @@ -31,8 +31,9 @@ const getFn = ai.tool({ schema: z.object({ name: z.string() }), call: async (input: { name: string }, _refs, ctx) => { try { - const def = ctx.store.readFn(input.name); - const type = ctx.registry.parse(def.type); + // `readFn` returns the TypeDef directly — `call.get` holds the body. + const typeDef = ctx.store.readFn(input.name); + const type = ctx.registry.parse(typeDef); return `${input.name}: ${type.toCode()}`; } catch { return `Function '${input.name}' not found.`; @@ -129,6 +130,12 @@ const createNewFn = ai.tool({ ``, `Parameters available: ${paramList}`, ``, + `## Recursion via \`recurse\``, + ``, + `The function itself is bound as the scope variable \`recurse\` — call it to recurse. Path: \`{ kind: "get", path: [{ prop: "recurse" }, { args: { ...new args... } }] }\`.`, + `Example pattern (factorial-style descent): test \`args.n\`; base-case returns a literal; recursive case calls \`recurse({ n: args.n.sub({ other: 1 }) })\` and combines that with \`args.n\`.`, + `Use \`recurse\` for any self-calls — do NOT try to look up the function by its eventual saved name (\`${input.name}\`); that name is not yet bound during testing.`, + ``, `## Inputs are PARAMETERS, not constants`, ``, `The body must operate on \`args.*\` — not on hardcoded sample values. test() will call your body with sample values to verify it works, but the SAVED body must compute its result from whichever values the caller passes in.`, diff --git a/packages/ginny/src/store.ts b/packages/ginny/src/store.ts index 3c00966..47db992 100644 --- a/packages/ginny/src/store.ts +++ b/packages/ginny/src/store.ts @@ -120,8 +120,9 @@ export function createStore(cwd: string): Store { }, searchFns(q) { - type T = { type?: TypeDef; body?: ExprDef; docs?: string }; - return searchDir( + // Saved fns are TypeDefs with the body in `call.get`. The + // top-level `docs` field is the function's description. + return searchDir( fnsDir, (name, d) => `${name}${d.docs ? ` — ${d.docs}` : ''}`, (name, d) => `${name} ${d.docs ?? ''}`, @@ -130,12 +131,12 @@ export function createStore(cwd: string): Store { }, readFn(name) { - return readJSON<{ type: TypeDef; body: ExprDef }>(path.join(fnsDir, `${name}.json`)); + return readJSON(path.join(fnsDir, `${name}.json`)); }, - writeFn(name, v) { + writeFn(name, def) { const file = path.join(fnsDir, `${name}.json`); - writeJSON(file, v); + writeJSON(file, def); return file; }, diff --git a/packages/ginny/src/tools/find-or-create-fns.ts b/packages/ginny/src/tools/find-or-create-fns.ts index 7c0d94a..3d3f2f8 100644 --- a/packages/ginny/src/tools/find-or-create-fns.ts +++ b/packages/ginny/src/tools/find-or-create-fns.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { ai } from '../ai'; import { runSubagent } from '../progress'; -import { registerFnAsGlobal } from '../fns-global'; import { MAX_PROGRAMMER_DEPTH } from '../context'; interface EngineerResult { @@ -38,28 +37,25 @@ export const findOrCreateFunctions = ai.tool({ for (const name of [...use, ...created]) { if (!ctx.loadedFns.has(name)) { try { - const def = ctx.store.readFn(name); - const type = ctx.registry.parse(def.type); - ctx.registry.register(type); - // Wire as a runtime callable so programs can invoke it. - // Without this the fn is only typed-known, not executable. - registerFnAsGlobal(ctx.engine, name, type, def.body); + // Saved fns are TypeDefs whose `call.get` IS the body. Parse + // and register with `value: null` — gin's path walker handles + // invocation, args binding, and recurse natively (see + // `gin/src/path.ts:283-290`). No callable wrapping needed. + const typeDef = ctx.store.readFn(name); + const fnType = ctx.registry.parse(typeDef); + ctx.engine.registerGlobal(name, { type: fnType, value: null }); ctx.loadedFns.add(name); } catch { // The engineer claimed this function exists but there's no - // file on disk. This happens when the engineer hallucinates a - // success in its structured output even though create_new_fn - // didn't actually write anything (e.g. inner programmer - // failed). Drop the ghost and surface it so the caller knows - // not to trust the engineer's claim. + // file on disk (or it failed to parse). Drop the ghost and + // surface it so the caller knows not to trust the claim. ghosts.push(name); continue; } } try { - const def = ctx.store.readFn(name); - const type = ctx.registry.parse(def.type); - loaded.push(`fn ${name}: ${type.toCode()}`); + const fnType = ctx.registry.parse(ctx.store.readFn(name)); + loaded.push(`fn ${name}: ${fnType.toCode()}`); } catch { loaded.push(`fn ${name}`); } diff --git a/packages/ginny/src/tools/finish.ts b/packages/ginny/src/tools/finish.ts index 76140f1..4d13727 100644 --- a/packages/ginny/src/tools/finish.ts +++ b/packages/ginny/src/tools/finish.ts @@ -1,16 +1,19 @@ import { z } from 'zod'; import { ToolInterrupt } from '@aeye/core'; +import type { TypeDef } from '@aeye/gin'; import { ai } from '../ai'; -import { registerFnAsGlobal } from '../fns-global'; /** * Finalize the draft after a successful test. If `saveAs` is provided - * the draft is also persisted as a callable function under - * `fns/.json` and registered in the engine immediately, so - * subsequent requests in this session can invoke it by name. This is - * how ginny's "everything is a function" model works — there's no - * separate `programs/` dir; a finalized program with no parameters is - * just a `fn() => T`. + * the draft is persisted as a single TypeDef whose `call.get` is the + * body — gin's native shape for a callable global (see + * `gin/src/__tests__/recurse.test.ts:267`). Subsequent requests in + * this session can invoke it by name; the path walker handles args + * binding and `recurse` automatically (`gin/src/path.ts:283-290`). + * + * This is how ginny's "everything is a function" model works — there's + * no separate `programs/` dir; a finalized program with no parameters + * is just a `fn() => T` with `call.get` = the program body. */ export const finish = ai.tool({ name: 'finish', @@ -27,7 +30,7 @@ export const finish = ai.tool({ docs: z .string() .optional() - .describe('Short description of what the saved function does.'), + .describe('Short description of what the saved function does. Stored on the TypeDef so search_fns surfaces it.'), }), applicable: (ctx) => !!ctx.runState.lastTest?.success, call: async (input: { saveAs?: string; docs?: string }, _refs, ctx) => { @@ -55,18 +58,29 @@ export const finish = ai.tool({ // intended signature lives on `ctx.targetFn`. Use it so the saved // type matches what the engineer designed instead of being // inferred from the body — `engine.typeOf(draft)` of an if/elif - // chain lands on weird unions like `or`, which is - // useless to callers expecting `(n: num) => list`. + // chain lands on weird unions like `or`, useless to + // callers expecting `(n: num) => list`. const useTarget = ctx.targetFn && ctx.targetFn.name === name; const argsType = useTarget ? ctx.targetFn!.argsType : r.obj({}); - const returnType = useTarget ? ctx.targetFn!.returnsType : ctx.engine.typeOf(draft); - const fnType = r.fn(argsType, returnType); + const returnsType = useTarget ? ctx.targetFn!.returnsType : ctx.engine.typeOf(draft); + + // Build the TypeDef with the body baked into `call.get`. Gin's + // path walker invokes this directly — no ginny-side callable + // wrapping needed. + const fnTypeDef: TypeDef = { + name: 'function', + ...(input.docs ? { docs: input.docs } : {}), + call: { + args: argsType.toJSON(), + returns: returnsType.toJSON(), + get: draft, + }, + }; + try { - ctx.store.writeFn(name, { type: fnType.toJSON(), body: draft }); - // Register only as a runtime global — FnType.name is always - // 'function', so calling registry.register(fnType) would clobber - // the canonical FnType class, not create a named entry. - registerFnAsGlobal(ctx.engine, name, fnType, draft); + ctx.store.writeFn(name, fnTypeDef); + const fnType = r.parse(fnTypeDef); + ctx.engine.registerGlobal(name, { type: fnType, value: null }); ctx.loadedFns.add(name); savedNote = ` (saved as fn '${name}': ${fnType.toCode()})`; } catch (e: unknown) { diff --git a/packages/ginny/src/tools/test.ts b/packages/ginny/src/tools/test.ts index cbfe479..a0899bf 100644 --- a/packages/ginny/src/tools/test.ts +++ b/packages/ginny/src/tools/test.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { ToolInterrupt } from '@aeye/core'; -import { val, type Value, type ObjType, type Registry } from '@aeye/gin'; +import { LambdaExpr, val, type Value, type ObjType, type Registry } from '@aeye/gin'; import { ai } from '../ai'; import { flushDirtyVars } from '../vars-global'; @@ -11,7 +11,7 @@ import { flushDirtyVars } from '../vars-global'; * set), use that obj type's value-side schema directly. The model * sees `{ n: number, m: string }` instead of an opaque * `Record` and stops trying to invent wrapper - * names like `obj` or `args` to read from scope. + * names like `obj` to read from scope. * - Otherwise (top-level / generic case) programs rarely take * external scope vars; keep a permissive record fallback so the * tool still works for ad-hoc one-off uses. @@ -19,49 +19,16 @@ import { flushDirtyVars } from '../vars-global'; function buildArgsSchema(argsType: ObjType | undefined): z.ZodTypeAny { if (argsType) { return argsType.toValueSchema({ includeDocs: 'all' }).describe( - `Scope variables — keys ARE the function's parameter names. Each key becomes a scope variable the program reads via { kind: 'get', path: [{ prop: '' }] }. Do NOT wrap in another object.`, + `Scope values — keys ARE the function's parameter names. The function body reads each via [{prop:'args'}, {prop:''}]. Pass concrete sample values matching the args type.`, ); } return z .record(z.string(), z.unknown()) .describe( - 'Scope variables — keys become variable names the program reads by name. NOT a single wrapper object; do NOT read `args` or `obj` from scope, read the names you put here.', + 'Scope variables for the top-level draft. Keys become variable names the program reads by name.', ); } -/** - * `engine.run`'s extras must be `Record` — the schema - * lets the model pass plain JSON, so we wrap on the way in. - * - * Gin's calling convention exposes a function's parameters as a single - * `args` scope variable, not as top-level scope entries (matches - * `Lambda.evaluate` in @aeye/gin/exprs/lambda.ts). Param names live - * under `args.*` so they can't collide with globals (`fns`, `vars`, - * loaded fns), `recurse`, or lambda context names (`this`, `super`, - * `key`, `value`). - * - * - With `argsType`, parse the entire args object through that obj - * type to get a typed `Value`, then bind it as `args`. - * - Without it (top-level / generic case) the model rarely passes - * args. Wrap as `val(any, raw)` for whatever shape it provided. - */ -function buildScopeExtras( - registry: Registry, - argsType: ObjType | undefined, - rawArgs: Record | undefined, -): Record { - if (argsType) { - try { - const parsed = argsType.parse(rawArgs ?? {}); - return { args: parsed }; - } catch { - // Parse failed — fall through to a permissive any-typed args. - } - } - if (!rawArgs) return {}; - return { args: val(registry.any(), rawArgs) }; -} - function formatError(err: unknown): string { if (err instanceof Error) return err.message || err.name || 'Error'; if (typeof err === 'string') return err; @@ -82,7 +49,7 @@ export const test = ai.tool({ name: 'test', description: 'Execute the stored draft program and return the result.', instructions: - 'Run the draft. `args` are scope variables the program reads by name — its schema reflects the function being authored when one is in scope, so just pass concrete values for each parameter. Set `expectError: true` if a runtime error is the expected outcome.', + 'Run the draft. `args` are the values bound under the `args` scope variable — its schema reflects the function being authored when one is in scope, so just pass concrete values for each parameter. Set `expectError: true` if a runtime error is the expected outcome.', schema: (ctx) => z.object({ args: buildArgsSchema(ctx.targetFn?.argsType).optional(), @@ -94,13 +61,15 @@ export const test = ai.tool({ _refs, ctx, ) => { - if (!ctx.runState.draft) { + const draft = ctx.runState.draft; + if (!draft) { throw new ToolInterrupt('No draft written yet. Call write() first.'); } try { - const scopeExtras = buildScopeExtras(ctx.registry, ctx.targetFn?.argsType, input.args); - const value = await ctx.engine.run(ctx.runState.draft, scopeExtras); + const value = ctx.targetFn + ? await invokeAsLambda(ctx.registry, ctx.engine, ctx.targetFn, draft, input.args) + : await invokeTopLevel(ctx.registry, ctx.engine, draft, input.args); const rawResult = value.type?.encode ? value.type.encode(value.raw) : value.raw; if (input.expectError) { @@ -127,3 +96,50 @@ export const test = ai.tool({ } }, }); + +/** + * Engineer-driven flow: the draft is a function body. + * + * Wrap it in a `LambdaExpr` and invoke through gin's standard call + * machinery so the body sees `args` and `recurse` in scope and + * `ReturnSignal` is unwrapped — exactly like the saved fn will behave + * once `finish` persists it. Without the lambda wrap, recurse and + * return-flow would silently misbehave during testing. + */ +async function invokeAsLambda( + registry: Registry, + engine: { createRootScope: () => any; registry: Registry }, + targetFn: { argsType: ObjType; returnsType: any }, + draft: any, + rawArgs: Record | undefined, +): Promise { + const fnType = registry.fn(targetFn.argsType, targetFn.returnsType); + const lambda = new LambdaExpr(fnType, registry.parseExpr(draft)); + // `engine.createRootScope()` seeds globals (fns, vars, loaded fns) + // so the body can call other saved fns; a hand-built scope wouldn't. + const lambdaValue = await lambda.evaluate(engine as any, engine.createRootScope()); + const argsValue = targetFn.argsType.parse(rawArgs ?? {}); + const callable = lambdaValue.raw as (a: Value) => Promise; + return await callable(argsValue); +} + +/** + * Top-level flow: the draft is just a program (not a fn body). + * + * Run it directly via `engine.run` so `ExitSignal` (used by `kind: + * 'exit'`) is unwrapped (engine.ts:74-84). No need for a lambda wrap — + * top-level isn't a function and doesn't use `args`/`recurse`. Args + * passed at this level are bound as a single permissive `args` value + * for ad-hoc uses. + */ +async function invokeTopLevel( + registry: Registry, + engine: { run: (expr: any, extras?: Record) => Promise }, + draft: any, + rawArgs: Record | undefined, +): Promise { + const extras: Record = rawArgs + ? { args: val(registry.any(), rawArgs) } + : {}; + return await engine.run(draft, extras); +} diff --git a/packages/ginny/src/tools/write.ts b/packages/ginny/src/tools/write.ts index d5a0139..b45d90b 100644 --- a/packages/ginny/src/tools/write.ts +++ b/packages/ginny/src/tools/write.ts @@ -31,13 +31,20 @@ export const write = ai.tool({ } // Build the type-scope `engine.validate` walks against. Globals - // are always there; when the engineer is authoring a fn, bind the - // entire args obj as a single `args` scope var (matches gin's - // runtime calling convention — see `fns-global.ts`). The body - // accesses params via `args.`. + // are always there; when the engineer is authoring a fn, also + // bind `args` (the parameter obj) and `recurse` (the function + // itself, for self-calls). Matches gin's runtime call binding — + // see `gin/src/path.ts:286-287` for the saved-fn path and + // `gin/src/exprs/lambda.ts:60-62` for the test path. + // + // Note: gin's `Lambda.validateWalk` (lambda.ts:90) only adds + // `args`, not `recurse` — that's a real upstream gap. Adding + // recurse here keeps ginny's static analysis aligned with what + // actually runs. const scope = new Map(ctx.engine.globalTypeScope()); if (ctx.targetFn) { scope.set('args', ctx.targetFn.argsType); + scope.set('recurse', ctx.registry.fn(ctx.targetFn.argsType, ctx.targetFn.returnsType)); } let problemsNote = ''; From cfa4ba0679021258593d236ba2b41e6ff87f8ec8 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Wed, 29 Apr 2026 19:00:58 -0400 Subject: [PATCH 03/21] Pre compaction savepoint --- .../src/__tests__/call-type-aliases.test.ts | 335 +++++++++++++ .../gin/src/__tests__/loop-while-bool.test.ts | 316 ++++++++++++ packages/gin/src/exprs/inline-aliases.ts | 448 ++++++++++++++++++ packages/gin/src/exprs/lambda.ts | 43 +- packages/gin/src/exprs/loop.ts | 67 ++- packages/gin/src/extension.ts | 13 +- packages/gin/src/schema.ts | 22 + packages/gin/src/schemas.ts | 14 + packages/gin/src/spec.ts | 108 +++-- packages/gin/src/type-scope.ts | 69 +++ packages/gin/src/type.ts | 142 +++++- packages/gin/src/types/bool.ts | 20 +- packages/gin/src/types/fn.ts | 8 +- packages/gin/src/types/iface.ts | 6 +- packages/ginny/src/context.ts | 22 +- packages/ginny/src/prompts/engineer.ts | 81 +++- packages/ginny/src/prompts/programmer.ts | 50 ++ .../ginny/src/tools/find-or-create-fns.ts | 16 +- .../ginny/src/tools/find-or-create-types.ts | 2 +- .../ginny/src/tools/find-or-create-vars.ts | 2 +- packages/ginny/src/tools/finish.ts | 25 +- packages/ginny/src/tools/research.ts | 2 +- packages/ginny/src/tools/web-search.ts | 2 +- 23 files changed, 1697 insertions(+), 116 deletions(-) create mode 100644 packages/gin/src/__tests__/call-type-aliases.test.ts create mode 100644 packages/gin/src/__tests__/loop-while-bool.test.ts create mode 100644 packages/gin/src/exprs/inline-aliases.ts create mode 100644 packages/gin/src/type-scope.ts diff --git a/packages/gin/src/__tests__/call-type-aliases.test.ts b/packages/gin/src/__tests__/call-type-aliases.test.ts new file mode 100644 index 0000000..bfdf273 --- /dev/null +++ b/packages/gin/src/__tests__/call-type-aliases.test.ts @@ -0,0 +1,335 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, FnType, ListType, type TypeDef } from '../index'; + +/** + * Call-level type aliases (`CallDef.types`) — verify the inliner + * resolves bare `{name: ''}` references in args/returns/throws + * /get/set, that round-trip preserves the source form, that + * substitution drops aliases, and that the various validation cases + * throw with the expected error codes. + */ + +const r = createRegistry(); +const e = new Engine(r); + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; + +describe('CallDef.types — basic resolution', () => { + test('alias referenced twice in args resolves identically to inlined form', () => { + const aliased = r.parse({ + name: 'function', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object', props: { a: { type: { name: 'counter' } }, b: { type: { name: 'counter' } } } }, + returns: { name: 'counter' }, + }, + }); + const inlined = r.parse({ + name: 'function', + call: { + args: { name: 'object', props: { + a: { type: { name: 'num', options: { whole: true, min: 1 } } }, + b: { type: { name: 'num', options: { whole: true, min: 1 } } }, + } }, + returns: { name: 'num', options: { whole: true, min: 1 } }, + }, + }); + expect(aliased.toCode()).toContain('a: num'); + expect(aliased.toCode()).toContain('b: num'); + // Structural equality on the inlined parsed forms. + expect((aliased as FnType)._call.args.toJSON()).toEqual((inlined as FnType)._call.args.toJSON()); + expect((aliased as FnType)._call.returns?.toJSON()).toEqual((inlined as FnType)._call.returns?.toJSON()); + }); + + test('sequential aliases — later refs earlier', () => { + const fn = r.parse({ + name: 'function', + call: { + types: { + A: { name: 'num', options: { whole: true, min: 1 } }, + B: { name: 'list', generic: { V: { name: 'A' } } }, + }, + args: { name: 'object', props: { items: { type: { name: 'B' } } } }, + returns: { name: 'A' }, + }, + }); + const items = ((fn as FnType)._call.args as unknown as { fields: Record }) + .fields['items']!.type as ListType; + expect(items.name).toBe('list'); + expect(items.item.name).toBe('num'); + // The element type's options carry through. + expect((items.item.options as { min?: number }).min).toBe(1); + }); + + test('alias references generic — bind substitutes inside the inlined tree', () => { + const fn = r.parse({ + name: 'function', + generic: { T: { name: 'generic', options: { name: 'T' } } }, + call: { + types: { + valueList: { name: 'list', generic: { V: { name: 'generic', options: { name: 'T' } } } }, + }, + args: { name: 'object', props: { items: { type: { name: 'valueList' } } } }, + returns: { name: 'generic', options: { name: 'T' } }, + }, + }); + const bound = fn.bind({ T: r.text() }); + const items = ((bound as FnType)._call.args as unknown as { fields: Record }) + .fields['items']!.type as ListType; + expect(items.item.name).toBe('text'); + }); +}); + +describe('CallDef.types — round-trip', () => { + test('toJSON preserves the source `types` map and alias references', () => { + const def: TypeDef = { + name: 'function', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object', props: { a: { type: { name: 'counter' } } } }, + returns: { name: 'counter' }, + }, + }; + const fn = r.parse(def); + const json = fn.toJSON(); + expect(json.call?.types).toBeDefined(); + expect(json.call?.types?.['counter']).toEqual({ name: 'num', options: { whole: true, min: 1 } }); + // The args slot still references the alias by name (NOT inlined). + expect(json.call?.args).toEqual({ name: 'object', props: { a: { type: { name: 'counter' } } } }); + expect(json.call?.returns).toEqual({ name: 'counter' }); + }); + + test('parse → toJSON → parse produces structurally identical inlined args', () => { + const def: TypeDef = { + name: 'function', + call: { + types: { + A: { name: 'num', options: { min: 0 } }, + B: { name: 'list', generic: { V: { name: 'A' } } }, + }, + args: { name: 'object', props: { xs: { type: { name: 'B' } } } }, + returns: { name: 'A' }, + }, + }; + const a = r.parse(def); + const b = r.parse(a.toJSON()); + expect((a as FnType)._call.args.toJSON()).toEqual((b as FnType)._call.args.toJSON()); + expect((a as FnType)._call.returns?.toJSON()).toEqual((b as FnType)._call.returns?.toJSON()); + }); + + test('post-bind toJSON drops `types` and emits inlined args', () => { + const fn = r.parse({ + name: 'function', + generic: { T: { name: 'generic', options: { name: 'T' } } }, + call: { + types: { box: { name: 'list', generic: { V: { name: 'generic', options: { name: 'T' } } } } }, + args: { name: 'object', props: { v: { type: { name: 'box' } } } }, + returns: { name: 'generic', options: { name: 'T' } }, + }, + }); + const bound = fn.bind({ T: r.text() }); + const j = bound.toJSON(); + expect(j.call?.types).toBeUndefined(); + // args is now the fully-inlined-and-bound form (list). + const v = (j.call?.args as { props?: Record }).props!['v']!.type; + expect(v.name).toBe('list'); + }); +}); + +describe('CallDef.types — validation errors', () => { + test('alias name conflicts with built-in class → throws', () => { + expect(() => r.parse({ + name: 'function', + call: { + types: { list: { name: 'num' } }, + args: { name: 'object' }, + }, + })).toThrow(/call\.types\.name-conflict/); + }); + + test('empty alias name → throws', () => { + expect(() => r.parse({ + name: 'function', + call: { + types: { '': { name: 'num' } }, + args: { name: 'object' }, + }, + })).toThrow(/call\.types\.empty-name/); + }); + + test('forward reference → throws', () => { + expect(() => r.parse({ + name: 'function', + call: { + types: { + A: { name: 'B' }, // refs B before B is declared + B: { name: 'num' }, + }, + args: { name: 'object' }, + }, + })).toThrow(/call\.types\.forward-ref/); + }); + + test('self reference → throws', () => { + expect(() => r.parse({ + name: 'function', + call: { + types: { recur: { name: 'list', generic: { V: { name: 'recur' } } } }, + args: { name: 'object' }, + }, + })).toThrow(/call\.types\.forward-ref/); + }); + + test('alias name in `extends` → throws extends-alias', () => { + expect(() => r.parse({ + name: 'function', + call: { + types: { Foo: { name: 'num' } }, + args: { name: 'object', props: { x: { type: { name: 'obj', extends: 'Foo' } } } }, + }, + })).toThrow(/call\.types\.extends-alias/); + }); + + test('alias name in `satisfies` → throws extends-alias', () => { + expect(() => r.parse({ + name: 'function', + call: { + types: { Bar: { name: 'num' } }, + args: { name: 'object', props: { x: { type: { name: 'obj', satisfies: ['Bar'] } } } }, + }, + })).toThrow(/call\.types\.extends-alias/); + }); +}); + +describe('CallDef.types — ExprDef bodies', () => { + test('alias referenced inside `call.get` body resolves correctly', async () => { + // counterFn() => 7 (where `counter` aliases num{min:1, whole:true}) + const fnType = r.parse({ + name: 'function', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object' }, + returns: { name: 'counter' }, + get: { kind: 'new', type: { name: 'counter' }, value: 7 }, + }, + }); + e.registerGlobal('counterFn', { type: fnType, value: null }); + const v = await e.run({ + kind: 'get', + path: [{ prop: 'counterFn' }, { args: {} }], + }); + expect(v.raw).toBe(7); + }); +}); + +describe('CallDef.types — lambdas inherit aliases from their fnType', () => { + test('lambda body referencing a call.types alias parses (was failing before)', () => { + const lam = r.parseExpr({ + kind: 'lambda', + type: { + name: 'function', + call: { + types: { positiveInt: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object' }, + returns: { name: 'positiveInt' }, + }, + }, + body: { kind: 'new', type: { name: 'positiveInt' }, value: 5 }, + }); + expect(lam.kind).toBe('lambda'); + // The body's parsed form has the alias inlined (so the engine sees + // a real num type, not an unresolvable name). + const bodyJson = lam.body.toJSON() as { type: { name: string; options?: { min?: number } } }; + expect(bodyJson.type.name).toBe('num'); + expect(bodyJson.type.options?.min).toBe(1); + }); + + test('lambda constraint can also reference call.types aliases', () => { + const lam = r.parseExpr({ + kind: 'lambda', + type: { + name: 'function', + call: { + types: { positiveInt: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object', props: { n: { type: { name: 'positiveInt' } } } }, + returns: { name: 'bool' }, + }, + }, + // Constraint compares args.n against a positiveInt literal. + constraint: { + kind: 'get', + path: [ + { prop: 'args' }, { prop: 'n' }, { prop: 'gte' }, + { args: { other: { kind: 'new', type: { name: 'positiveInt' }, value: 1 } } }, + ], + }, + body: { kind: 'new', type: { name: 'bool' }, value: true }, + }); + expect(lam.constraint).toBeDefined(); + }); + + test('lambda toJSON round-trips with alias refs intact in body', () => { + const def = { + kind: 'lambda' as const, + type: { + name: 'function', + call: { + types: { positiveInt: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object' }, + returns: { name: 'positiveInt' }, + }, + }, + body: { kind: 'new' as const, type: { name: 'positiveInt' }, value: 5 }, + }; + const lam = r.parseExpr(def); + const json = lam.toJSON() as { body: { type: { name: string } } }; + // Source body preserved — emits `{name: 'positiveInt'}`, NOT the + // inlined `{name: 'num', options: {...}}`. + expect(json.body.type.name).toBe('positiveInt'); + // Re-parse should still work and produce the same result. + const lam2 = r.parseExpr(json as never); + const body2 = lam2.body.toJSON() as { type: { name: string } }; + expect(body2.type.name).toBe('num'); // re-inlined on parse + }); + + test('lambda WITHOUT call.types behaves exactly as before', () => { + const lam = r.parseExpr({ + kind: 'lambda', + type: { + name: 'function', + call: { + args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + returns: { name: 'num' }, + }, + }, + body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] }, + }); + const json = lam.toJSON() as { body: { kind: string } }; + expect(json.body.kind).toBe('get'); + }); +}); + +describe('CallDef.types — toCodeDefinition rendering', () => { + test('aliases render as `type X = …;` lines before the call signature', () => { + const fn = r.parse({ + name: 'function', + call: { + types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, + args: { name: 'object', props: { n: { type: { name: 'counter' } } } }, + returns: { name: 'counter' }, + }, + }); + const def = fn.toCodeDefinition(); + // The alias line precedes the call-signature line. + const aliasIdx = def.indexOf('type counter'); + const callIdx = def.indexOf('(n:'); + expect(aliasIdx).toBeGreaterThanOrEqual(0); + expect(callIdx).toBeGreaterThanOrEqual(0); + expect(aliasIdx).toBeLessThan(callIdx); + // The call sig itself uses the alias-resolved name (num), since + // formatParams renders parsed Types — whether it shows `counter` + // or `num` depends on the inlined tree. We just confirm the + // signature is present and the alias header is too. + expect(def).toContain('type counter'); + }); +}); diff --git a/packages/gin/src/__tests__/loop-while-bool.test.ts b/packages/gin/src/__tests__/loop-while-bool.test.ts new file mode 100644 index 0000000..96e7be3 --- /dev/null +++ b/packages/gin/src/__tests__/loop-while-bool.test.ts @@ -0,0 +1,316 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, val } from '../index'; + +/** + * `LoopExpr` accepts a bool-typed `over` expression and re-evaluates + * it each iteration — true while-loop semantics. The loop continues + * while the value is `true` and exits when it flips to `false`. + * + * - The loop body sees `key` (num iteration index) and `value` + * (the bool's value, always `true` at body entry). + * - `flow:break` and `flow:continue` work as in any loop. + * - Static `validate()` no longer flags bool over as + * `loop.not-iterable`. Parallel options on a bool over flag as + * `loop.parallel.bool`. + */ + +const r = createRegistry(); +const e = new Engine(r); + +const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; +const boolLit = (b: boolean) => ({ kind: 'new', type: { name: 'bool' }, value: b }) as const; + +describe('LoopExpr — bool while-loop semantics', () => { + test('initial false → body runs zero times', async () => { + // Set a `ran` var to 1, loop should NOT execute, var stays 1. + const result = await e.run({ + kind: 'define', + vars: [{ name: 'ran', value: numLit(1) }], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: boolLit(false), + body: { + kind: 'set', + path: [{ prop: 'ran' }], + value: numLit(99), + }, + }, + { kind: 'get', path: [{ prop: 'ran' }] }, + ], + }, + }); + expect(result.raw).toBe(1); + }); + + test('expression re-evaluates each iteration: counts down to zero', async () => { + // Counter starts at 3; loop while counter > 0; body decrements. + // Should run 3 times (3, 2, 1) and exit when counter reaches 0. + const result = await e.run({ + kind: 'define', + vars: [{ name: 'counter', value: numLit(3) }], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + // counter > 0 — re-evaluated every iteration. + over: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'gt' }, + { args: { other: numLit(0) } }, + ], + }, + body: { + kind: 'set', + path: [{ prop: 'counter' }], + value: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'sub' }, + { args: { other: numLit(1) } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'counter' }] }, + ], + }, + }); + expect(result.raw).toBe(0); + }); + + test('break exits the loop early', async () => { + // Loop while true forever, break when key === 5. + const result = await e.run({ + kind: 'define', + vars: [{ name: 'last', value: numLit(-1) }], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: boolLit(true), + body: { + kind: 'block', + lines: [ + { + kind: 'set', + path: [{ prop: 'last' }], + value: { kind: 'get', path: [{ prop: 'key' }] }, + }, + { + kind: 'if', + ifs: [{ + condition: { + kind: 'get', + path: [ + { prop: 'key' }, { prop: 'gte' }, + { args: { other: numLit(5) } }, + ], + }, + body: { kind: 'flow', action: 'break' }, + }], + }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'last' }] }, + ], + }, + }); + expect(result.raw).toBe(5); + }); + + test('continue jumps to next iteration', async () => { + // Decrement counter, increment hits only on iterations where + // counter is still > 0. With continue at iteration 0 we still + // hit the counter mutation BEFORE the continue is reached, so + // verify the iteration index advances. + const result = await e.run({ + kind: 'define', + vars: [ + { name: 'counter', value: numLit(3) }, + { name: 'hits', value: numLit(0) }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'gt' }, + { args: { other: numLit(0) } }, + ], + }, + body: { + kind: 'block', + lines: [ + // Always decrement counter. + { + kind: 'set', + path: [{ prop: 'counter' }], + value: { + kind: 'get', + path: [ + { prop: 'counter' }, { prop: 'sub' }, + { args: { other: numLit(1) } }, + ], + }, + }, + // Skip the hits++ when key === 0 via continue. + { + kind: 'if', + ifs: [{ + condition: { + kind: 'get', + path: [ + { prop: 'key' }, { prop: 'eq' }, + { args: { other: numLit(0) } }, + ], + }, + body: { kind: 'flow', action: 'continue' }, + }], + }, + // Increment hits otherwise. + { + kind: 'set', + path: [{ prop: 'hits' }], + value: { + kind: 'get', + path: [ + { prop: 'hits' }, { prop: 'add' }, + { args: { other: numLit(1) } }, + ], + }, + }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'hits' }] }, + ], + }, + }); + // 3 iterations (counter 3→2→1→0). On iter 0 we continue (skip hits). + // On iter 1 and iter 2 we hit. So hits=2. + expect(result.raw).toBe(2); + }); + + test('binds key=num{whole, min:0} and value=bool in the body scope', async () => { + // Verify body sees correct types — read key + value, return them. + const result = await e.run({ + kind: 'define', + vars: [ + { name: 'idx', value: numLit(-1) }, + { name: 'lastV', value: { kind: 'new', type: { name: 'bool' }, value: false } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + // Run exactly one iteration. + over: { + kind: 'get', + path: [ + { prop: 'idx' }, { prop: 'lt' }, + { args: { other: numLit(0) } }, + ], + }, + body: { + kind: 'block', + lines: [ + { + kind: 'set', + path: [{ prop: 'idx' }], + value: { kind: 'get', path: [{ prop: 'key' }] }, + }, + { + kind: 'set', + path: [{ prop: 'lastV' }], + value: { kind: 'get', path: [{ prop: 'value' }] }, + }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'idx' }] }, + ], + }, + }); + // First iter has key=0; setting idx=0 makes the next over-eval + // false (idx < 0 → false); loop exits. So idx ends at 0. + expect(result.raw).toBe(0); + }); +}); + +describe('GetSet.loopDynamic — bool opts in via the flag', () => { + test('BoolType.get() returns a GetSet with loopDynamic: true and no `loop` ExprDef', () => { + const gs = r.bool().get(); + expect(gs).toBeDefined(); + expect(gs!.loopDynamic).toBe(true); + expect(gs!.loop).toBeUndefined(); + expect(gs!.key.name).toBe('num'); + expect(gs!.value.name).toBe('bool'); + }); + + test('list iterables remain static (loop ExprDef present, no loopDynamic)', () => { + const gs = r.list(r.num()).get(); + expect(gs).toBeDefined(); + expect(gs!.loop).toBeDefined(); + expect(gs!.loopDynamic).toBeFalsy(); + }); +}); + +describe('LoopExpr — validation accepts bool over', () => { + test('bool over does NOT flag loop.not-iterable', () => { + const probs = e.validate({ + kind: 'loop', + over: boolLit(true), + body: { kind: 'flow', action: 'break' }, + }); + expect(probs.list.some((p) => p.code === 'loop.not-iterable')).toBe(false); + }); + + test('parallel options on a dynamic (bool) loop flag loop.parallel.dynamic', () => { + const probs = e.validate({ + kind: 'loop', + over: boolLit(true), + parallel: { concurrent: numLit(2) }, + body: { kind: 'flow', action: 'break' }, + }); + expect(probs.list.some((p) => p.code === 'loop.parallel.dynamic')).toBe(true); + }); + + test('non-iterable, non-bool over still flags loop.not-iterable', () => { + // date has no `get().loop` defined, and isn't bool — so it should + // still trigger the not-iterable error. + const probs = e.validate({ + kind: 'loop', + over: { kind: 'new', type: { name: 'date' }, value: '2026-04-30' }, + body: { kind: 'block', lines: [] }, + }); + expect(probs.list.some((p) => p.code === 'loop.not-iterable')).toBe(true); + }); + + test('body sees key as num{whole,min:0} and value as bool', () => { + // Validate that referencing key.add(...) in the body type-checks. + const probs = e.validate({ + kind: 'loop', + over: boolLit(true), + body: { + kind: 'get', + path: [ + { prop: 'key' }, { prop: 'add' }, + { args: { other: numLit(1) } }, + ], + }, + }); + expect(probs.list.some((p) => p.code === 'var.unknown')).toBe(false); + expect(probs.list.some((p) => p.code === 'prop.unknown')).toBe(false); + }); +}); diff --git a/packages/gin/src/exprs/inline-aliases.ts b/packages/gin/src/exprs/inline-aliases.ts new file mode 100644 index 0000000..77dc3db --- /dev/null +++ b/packages/gin/src/exprs/inline-aliases.ts @@ -0,0 +1,448 @@ +/** + * Call-level type-alias inliner. + * + * `CallDef.types` declares a sequential map of locally-scoped TypeDefs + * that resolve in `args` / `returns` / `throws` / `get` / `set` via + * bare `{name: ''}` references. This module's two walkers + * substitute those references with deep clones of the alias's source, + * producing fully-inlined TypeDefs/ExprDefs that the existing decoders + * (`decodeCall`, `registry.parse`) can consume without scope plumbing. + * + * Both walkers are pure JSON-in / JSON-out — no registry access, no + * Type construction. Field shapes come from `schema.ts`. + * + * Why this rather than reusing `spec.ts:substituteChildren`? That + * helper round-trips through `registry.parse(def).substitute().toJSON()` + * (`spec.ts:19-25`). On a TypeDef containing an alias name not in the + * registry, `registry.parse` throws before any rewriting can happen. + * It also misses several slots we need (constraint, init.run, ExprDef + * trees inside call.get/set / new.type / lambda / native / define). + */ +import type { + CallDef, + ExprDef, + PathDef, + PathStepDef, + PathCallDef, + PathIndexDef, + TypeDef, +} from '../schema'; +import type { Registry } from '../registry'; +import { TypeError } from '../problem'; + +export type AliasMap = Record; + +const ALIAS_REF_DISALLOWED_PEERS: ReadonlyArray = [ + 'extends', 'satisfies', 'generic', 'options', 'init', + 'props', 'get', 'call', 'constraint', +]; + +/** True if `def` is a bare alias reference: `{name}` with optional + * `docs` only, and `name` is in `aliases`. Any structural peer + * (extends/options/generic/etc.) means it's NOT a ref — let the + * registry/type machinery handle it normally. */ +function isAliasRef(def: TypeDef, aliases: AliasMap): boolean { + if (!aliases[def.name]) return false; + for (const k of ALIAS_REF_DISALLOWED_PEERS) { + if ((def as unknown as Record)[k] !== undefined) return false; + } + return true; +} + +/** Deep-clone a TypeDef. Used so each inlined site is independent — + * later substitution (.bind) on one site doesn't bleed into others. */ +function cloneTypeDef(def: TypeDef): TypeDef { + return JSON.parse(JSON.stringify(def)) as TypeDef; +} + +/** + * Walk a TypeDef substituting bare alias references with deep clones + * of the alias's source. Recurses through every TypeDef-bearing slot. + */ +export function inlineTypeDef(def: TypeDef, aliases: AliasMap): TypeDef { + if (isAliasRef(def, aliases)) { + return cloneTypeDef(aliases[def.name]!); + } + + const next: TypeDef = { ...def }; + + // Defensive: an alias name appearing in `extends` or `satisfies` is a + // user error — aliases can't be extended; aliases must be referenced + // by the bare `{name}` shape. + if (def.extends && aliases[def.extends]) { + throw new TypeError({ + path: ['extends'], + code: 'call.types.extends-alias', + message: `'${def.extends}' is a call-local type alias and cannot be used as 'extends'. Use a registered named type, or write the alias's def inline.`, + severity: 'error', + }); + } + if (def.satisfies) { + for (const s of def.satisfies) { + if (aliases[s]) { + throw new TypeError({ + path: ['satisfies'], + code: 'call.types.extends-alias', + message: `'${s}' is a call-local type alias and cannot be used in 'satisfies'.`, + severity: 'error', + }); + } + } + } + + if (def.generic) { + const g: Record = {}; + for (const [k, v] of Object.entries(def.generic)) { + g[k] = inlineTypeDef(v, aliases); + } + next.generic = g; + } + + // `or<...>` carries its variants on `options.types`, not on the + // standard `generic`/`props` slots. Walk them so an alias used in + // `or` resolves correctly. + if (def.options && Array.isArray((def.options as { types?: TypeDef[] }).types)) { + const options = { ...(def.options as Record) }; + options['types'] = ((def.options as { types: TypeDef[] }).types).map((t) => inlineTypeDef(t, aliases)); + next.options = options; + } + + if (def.props) { + const p: Record[string]> = {}; + for (const [k, pd] of Object.entries(def.props)) { + p[k] = { + ...pd, + type: inlineTypeDef(pd.type, aliases), + get: pd.get ? inlineExprDef(pd.get, aliases) : undefined, + set: pd.set ? inlineExprDef(pd.set, aliases) : undefined, + default: pd.default ? inlineExprDef(pd.default, aliases) : undefined, + }; + } + next.props = p; + } + + if (def.get) { + next.get = { + ...def.get, + key: inlineTypeDef(def.get.key, aliases), + value: inlineTypeDef(def.get.value, aliases), + get: def.get.get ? inlineExprDef(def.get.get, aliases) : undefined, + set: def.get.set ? inlineExprDef(def.get.set, aliases) : undefined, + loop: def.get.loop ? inlineExprDef(def.get.loop, aliases) : undefined, + }; + } + + if (def.call) { + next.call = { + ...def.call, + args: inlineTypeDef(def.call.args, aliases), + returns: def.call.returns ? inlineTypeDef(def.call.returns, aliases) : undefined, + throws: def.call.throws ? inlineTypeDef(def.call.throws, aliases) : undefined, + get: def.call.get ? inlineExprDef(def.call.get, aliases) : undefined, + set: def.call.set ? inlineExprDef(def.call.set, aliases) : undefined, + // NOTE: a NESTED call's own `types` map is its own scope. Don't + // strip it here; let the inner `decodeCall` process it. Outer + // aliases STILL inline into inner non-types slots, which is the + // expected behavior — outer aliases are visible inside inner + // until the inner shadows. + }; + } + + if (def.init) { + next.init = { + ...def.init, + args: inlineTypeDef(def.init.args, aliases), + run: inlineExprDef(def.init.run, aliases), + }; + } + + if (def.constraint) { + next.constraint = inlineExprDef(def.constraint, aliases); + } + + return next; +} + +/** + * Walk an ExprDef substituting alias references inside any embedded + * TypeDefs. Recurses into child ExprDefs. + */ +export function inlineExprDef(expr: ExprDef, aliases: AliasMap): ExprDef { + const next: ExprDef = { ...expr }; + + switch (expr.kind) { + case 'new': { + const e = expr as ExprDef & { type: TypeDef; value?: unknown }; + next['type'] = inlineTypeDef(e.type, aliases); + // `value` may itself contain ExprDefs (e.g. for new list / new + // obj — slots are Exprs). Recurse via a permissive walker. + if (e.value !== undefined) next['value'] = inlineNewValue(e.value, aliases); + return next; + } + case 'lambda': { + const e = expr as ExprDef & { type: TypeDef; body: ExprDef; constraint?: ExprDef }; + next['type'] = inlineTypeDef(e.type, aliases); + next['body'] = inlineExprDef(e.body, aliases); + if (e.constraint) next['constraint'] = inlineExprDef(e.constraint, aliases); + return next; + } + case 'native': { + const e = expr as ExprDef & { type?: TypeDef }; + if (e.type) next['type'] = inlineTypeDef(e.type, aliases); + return next; + } + case 'define': { + const e = expr as ExprDef & { vars: Array<{ name: string; type?: TypeDef; value: ExprDef }>; body: ExprDef }; + next['vars'] = e.vars.map((v) => ({ + name: v.name, + type: v.type ? inlineTypeDef(v.type, aliases) : undefined, + value: inlineExprDef(v.value, aliases), + })); + next['body'] = inlineExprDef(e.body, aliases); + return next; + } + case 'block': { + const e = expr as ExprDef & { lines: ExprDef[] }; + next['lines'] = e.lines.map((l) => inlineExprDef(l, aliases)); + return next; + } + case 'if': { + const e = expr as ExprDef & { ifs: Array<{ condition: ExprDef; body: ExprDef }>; else?: ExprDef }; + next['ifs'] = e.ifs.map((b) => ({ + condition: inlineExprDef(b.condition, aliases), + body: inlineExprDef(b.body, aliases), + })); + if (e.else) next['else'] = inlineExprDef(e.else, aliases); + return next; + } + case 'switch': { + const e = expr as ExprDef & { value: ExprDef; cases: Array<{ equals: ExprDef[]; body: ExprDef }>; else?: ExprDef }; + next['value'] = inlineExprDef(e.value, aliases); + next['cases'] = e.cases.map((c) => ({ + equals: c.equals.map((eq) => inlineExprDef(eq, aliases)), + body: inlineExprDef(c.body, aliases), + })); + if (e.else) next['else'] = inlineExprDef(e.else, aliases); + return next; + } + case 'loop': { + const e = expr as ExprDef & { over: ExprDef; body: ExprDef; parallel?: { concurrent?: ExprDef; rate?: ExprDef } }; + next['over'] = inlineExprDef(e.over, aliases); + next['body'] = inlineExprDef(e.body, aliases); + if (e.parallel) { + next['parallel'] = { + concurrent: e.parallel.concurrent ? inlineExprDef(e.parallel.concurrent, aliases) : undefined, + rate: e.parallel.rate ? inlineExprDef(e.parallel.rate, aliases) : undefined, + }; + } + return next; + } + case 'template': { + const e = expr as ExprDef & { template: unknown; params: ExprDef }; + // template can be a string OR an ExprDef. Inline only when it's + // an Expr-shaped object. + if (e.template && typeof e.template === 'object' && 'kind' in (e.template as object)) { + next['template'] = inlineExprDef(e.template as ExprDef, aliases); + } + next['params'] = inlineExprDef(e.params, aliases); + return next; + } + case 'flow': { + const e = expr as ExprDef & { value?: ExprDef; error?: ExprDef }; + if (e.value) next['value'] = inlineExprDef(e.value, aliases); + if (e.error) next['error'] = inlineExprDef(e.error, aliases); + return next; + } + case 'set': { + const e = expr as ExprDef & { path: PathDef; value: ExprDef }; + next['path'] = inlinePath(e.path, aliases); + next['value'] = inlineExprDef(e.value, aliases); + return next; + } + case 'get': { + const e = expr as ExprDef & { path: PathDef }; + next['path'] = inlinePath(e.path, aliases); + return next; + } + default: + // Unknown kind — leave as-is. New expr kinds added later should + // teach this walker about their TypeDef-bearing slots. + return next; + } +} + +/** PathDef step list — `args` map of ExprDefs, `generic` map of TypeDefs, + * `key` ExprDef, `catch` ExprDef. Walks all of them. */ +function inlinePath(path: PathDef, aliases: AliasMap): PathDef { + return path.map((step) => inlinePathStep(step, aliases)); +} + +function inlinePathStep(step: PathStepDef, aliases: AliasMap): PathStepDef { + if ('prop' in step) return step; + if ('args' in step) { + const c = step as PathCallDef; + const out: PathCallDef = { + args: Object.fromEntries( + Object.entries(c.args).map(([k, v]) => [k, inlineExprDef(v, aliases)]), + ), + }; + if (c.generic) { + out.generic = Object.fromEntries( + Object.entries(c.generic).map(([k, v]) => [k, inlineTypeDef(v, aliases)]), + ); + } + if (c.catch) out.catch = inlineExprDef(c.catch, aliases); + return out; + } + // index step + const i = step as PathIndexDef; + return { key: inlineExprDef(i.key, aliases) }; +} + +/** + * `new.value` is a permissive shape — it depends on the type's + * `toNewSchema`. Composites accept Expr slots; primitives accept raw + * values. We only need to recurse where the value LOOKS like an + * ExprDef (object with `kind`) or where it's a structure containing + * ExprDefs (arrays for list-new, records for obj-new). + */ +function inlineNewValue(value: unknown, aliases: AliasMap): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) { + return value.map((v) => inlineNewValue(v, aliases)); + } + if ('kind' in (value as object) && typeof (value as { kind: unknown }).kind === 'string') { + return inlineExprDef(value as ExprDef, aliases); + } + // Plain record (e.g. obj-new value) — recurse into its values. + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = inlineNewValue(v, aliases); + } + return out; +} + +/** + * Validate alias names BEFORE inlining begins. Throws `TypeError` with + * a namespaced code on the first offence — matches `decodeCall`'s + * existing throw-on-structural-error convention. + */ +export function validateAliasNames(names: ReadonlyArray, registry: Registry): void { + const classNames = new Set(registry.typeClasses().map((c) => c.NAME)); + for (const name of names) { + if (name === '') { + throw new TypeError({ + path: ['types'], + code: 'call.types.empty-name', + message: 'call.types alias name cannot be empty', + severity: 'error', + }); + } + if (classNames.has(name)) { + throw new TypeError({ + path: ['types', name], + code: 'call.types.name-conflict', + message: `call.types alias '${name}' shadows a built-in type class — pick a different name`, + severity: 'error', + }); + } + } +} + +/** + * Build the alias source map for a CallDef. Each alias's value is + * `inlineTypeDef(def, prevAliases)` so later aliases see earlier ones. + * Returns an empty map if `types` is undefined or empty. + * + * Forward / self references are caught implicitly: if alias B + * references alias A and A hasn't been declared yet, the bare-ref + * lookup misses (returns undefined) and the bare ref survives into + * the parsed output, where `registry.parse` then throws "unknown + * type" — but that error is opaque. We surface a clearer one by + * checking after the inline pass. + */ +export function buildAliasMap(types: Record | undefined, registry: Registry): AliasMap { + if (!types) return {}; + const names = Object.keys(types); + validateAliasNames(names, registry); + const allNames = new Set(names); + + const aliases: AliasMap = {}; + for (const name of names) { + const inlined = inlineTypeDef(types[name]!, aliases); + // After inlining-against-prior, any surviving bare reference to a + // name that's ALSO in the full alias name set is a forward (or + // self) reference — that ref couldn't be resolved because its + // target hadn't been declared yet. Refs to names outside the + // alias set fall through to the registry at parse time, which + // throws its own "unknown type" — leave those alone here. + const offending = findBareRefToAny(inlined, allNames); + if (offending) { + throw new TypeError({ + path: ['types', name], + code: 'call.types.forward-ref', + message: `call.types alias '${name}' references '${offending}' before it's declared (or itself) — declare prerequisites earlier in the types map`, + severity: 'error', + }); + } + aliases[name] = inlined; + } + return aliases; +} + +/** Walk `def` and return the first bare-ref name (if any) that + * appears in `names`. Used by `buildAliasMap` to detect forward / + * self references after each alias has been inlined-against-prior: + * a surviving bare ref to a name ALSO declared in the alias set is + * necessarily one that wasn't yet declared at inline time. */ +function findBareRefToAny(def: TypeDef, names: ReadonlySet): string | undefined { + if (names.has(def.name)) { + let bare = true; + for (const k of ALIAS_REF_DISALLOWED_PEERS) { + if ((def as unknown as Record)[k] !== undefined) { bare = false; break; } + } + if (bare) return def.name; + } + if (def.generic) { + for (const v of Object.values(def.generic)) { + const f = findBareRefToAny(v, names); if (f) return f; + } + } + if (def.props) { + for (const pd of Object.values(def.props)) { + const f = findBareRefToAny(pd.type, names); if (f) return f; + } + } + if (def.call) { + let f = findBareRefToAny(def.call.args, names); if (f) return f; + if (def.call.returns) { f = findBareRefToAny(def.call.returns, names); if (f) return f; } + if (def.call.throws) { f = findBareRefToAny(def.call.throws, names); if (f) return f; } + } + if (def.init) { + const f = findBareRefToAny(def.init.args, names); if (f) return f; + } + if (def.get) { + let f = findBareRefToAny(def.get.key, names); if (f) return f; + f = findBareRefToAny(def.get.value, names); if (f) return f; + } + if (def.options && Array.isArray((def.options as { types?: TypeDef[] }).types)) { + for (const t of (def.options as { types: TypeDef[] }).types) { + const f = findBareRefToAny(t, names); if (f) return f; + } + } + return undefined; +} + +/** Inline an entire CallDef's slots against the supplied alias map. + * Returns a new CallDef WITHOUT the `types` field (inlined output is + * alias-free). Source CallDef is not mutated. */ +export function inlineCallDef(def: CallDef, aliases: AliasMap): CallDef { + return { + docs: def.docs, + args: inlineTypeDef(def.args, aliases), + returns: def.returns ? inlineTypeDef(def.returns, aliases) : undefined, + throws: def.throws ? inlineTypeDef(def.throws, aliases) : undefined, + get: def.get ? inlineExprDef(def.get, aliases) : undefined, + set: def.set ? inlineExprDef(def.set, aliases) : undefined, + }; +} diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index 421698c..60d81e5 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -1,6 +1,6 @@ import type { Engine } from '../engine'; import type { Scope } from '../scope'; -import type { LambdaExprDef } from '../schema'; +import type { ExprDef, LambdaExprDef, TypeDef } from '../schema'; import { Value } from '../value'; import { ReturnSignal } from '../flow-control'; import type { Registry } from '../registry'; @@ -12,6 +12,7 @@ import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import { buildAliasMap, inlineExprDef } from './inline-aliases'; /** * LambdaExpr — a callable value that closes over the lexical scope. @@ -28,16 +29,41 @@ export class LambdaExpr extends Expr { readonly fnType: Type, readonly body: Expr, readonly constraint?: Expr, + /** Source-form ExprDefs (with alias references intact) for + * round-trip via `toJSON()` when `fnType.call.types` declared + * call-local aliases. Without these the parsed body's `toJSON()` + * would emit the inlined form, defeating the verbosity-reduction + * point of aliases. */ + readonly _sourceBody?: ExprDef, + readonly _sourceConstraint?: ExprDef, ) { super(); } static from(json: LambdaExprDef, registry: Registry): LambdaExpr { - const constraint = json.constraint ? registry.parseExpr(json.constraint) : undefined; + // If the lambda's fnType declares `call.types`, those aliases must + // resolve in the body / constraint too — same convention as a + // saved fn's `call.get` / `call.set` (see `decodeCall`). Inline + // before parsing so referenced alias names don't trip + // `registry.parse`'s "unknown type" check. + const callTypes = (json.type as { call?: { types?: Record } }) + .call?.types; + const aliases = callTypes ? buildAliasMap(callTypes, registry) : undefined; + const hasAliases = !!aliases && Object.keys(aliases).length > 0; + const bodyDef = hasAliases ? inlineExprDef(json.body, aliases!) : json.body; + const constraintDef = json.constraint + ? (hasAliases ? inlineExprDef(json.constraint, aliases!) : json.constraint) + : undefined; + + const fnType = registry.parse(json.type); + const body = registry.parseExpr(bodyDef); + const constraint = constraintDef ? registry.parseExpr(constraintDef) : undefined; return new LambdaExpr( - registry.parse(json.type), - registry.parseExpr(json.body), + fnType, + body, constraint, + hasAliases ? json.body : undefined, + hasAliases && json.constraint ? json.constraint : undefined, ).withComment(json.comment); } @@ -134,8 +160,11 @@ export class LambdaExpr extends Expr { return this.withCommentOn({ kind: 'lambda', type: this.fnType.toJSON(), - body: this.body.toJSON(), - constraint: this.constraint?.toJSON(), + // Prefer source forms when present so aliased bodies round-trip + // compact. The parsed body has already been inlined; emitting + // it would lose the alias references the user wrote. + body: this._sourceBody ?? this.body.toJSON(), + constraint: this._sourceConstraint ?? this.constraint?.toJSON(), }); } @@ -144,6 +173,8 @@ export class LambdaExpr extends Expr { this.fnType.clone(), this.body.clone(), this.constraint?.clone(), + this._sourceBody, + this._sourceConstraint, ).withComment(this.comment); } diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 04357cc..142f58b 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -55,7 +55,10 @@ export class LoopExpr extends Expr { kind: z.literal('loop'), ...baseExprFields, over: opts.Expr.describe( - 'The iterable to walk — must evaluate to a value whose type defines `get().loop` (lists by index, maps by key, etc.). NOT a bool: gin has no while-loop; a finite iterable is required.', + 'The iterable expression. Two evaluation modes: ' + + '(1) iterable types (list, map, etc. — anything whose `get().loop` is defined) iterate once; the expression is evaluated ONCE at the start. ' + + '(2) bool — while-loop semantics: the expression is RE-EVALUATED each iteration; the loop continues while the value is `true` and exits the moment it becomes `false`. ' + + 'Use `flow:break` / `flow:continue` inside the body to control iteration regardless of mode.', ), body: opts.Expr.describe( "Evaluated once per iteration with the current `key` and `value` bound in scope. Use `{kind:'flow', action:'break'}` or `'continue'` for early-exit. The loop expression itself returns void.", @@ -85,13 +88,52 @@ export class LoopExpr extends Expr { async evaluate(engine: Engine, scope: Scope): Promise { const over = await this.over.evaluate(engine, scope); const gs = over.type.get(); - if (!gs?.loop) { - throw new Error(`loop: type '${over.type.name}' has no loop defined on its GetSet`); + // A type is iterable iff its GetSet declares EITHER a `loop` + // ExprDef (static — e.g. list/map iterate via the native) OR + // `loopDynamic: true` (dynamic — e.g. bool while-loop). + const iterable = !!(gs?.loop || gs?.loopDynamic); + if (!iterable) { + throw new Error(`loop: type '${over.type.name}' has no loop or loopDynamic defined on its GetSet`); } const keyName = this.keyName ?? 'key'; const valueName = this.valueName ?? 'value'; + // Dynamic mode: re-evaluate `over` against the OUTER scope each + // iteration; continue while the value's `raw` is truthy. Body + // mutations (via `set`) on vars the expression reads drive the + // exit condition. `key` is the iteration index, `value` is the + // current re-evaluated value. Bool's GetSet sets this flag for + // while-loop semantics; other types can opt in similarly. Parallel + // options aren't meaningful in this mode (analyzer warns). + if (gs.loopDynamic) { + let current: Value = over; + let iteration = 0; + while (current.raw) { + const iter = scope.child({ + [keyName]: val(engine.registry.num({ whole: true, min: 0 }), iteration), + [valueName]: current, + }); + try { + await this.body.evaluate(engine, iter); + } catch (sig) { + if (sig instanceof BreakSignal) break; + if (!(sig instanceof ContinueSignal)) throw sig; + } + iteration++; + current = await this.over.evaluate(engine, scope); + } + return val(engine.registry.void(), undefined); + } + + // Static (iterable) path — gs.loop is required here. The check + // at the top rules out the (no loop AND no loopDynamic) case, but + // TS can't narrow through the OR so we re-assert. + const loopExpr = gs.loop; + if (!loopExpr) { + throw new Error(`loop: type '${over.type.name}' has no loop ExprDef on its GetSet`); + } + const concurrent = this.parallel?.concurrent ? Number((await this.parallel.concurrent.evaluate(engine, scope)).raw) : undefined; @@ -111,7 +153,7 @@ export class LoopExpr extends Expr { throw sig; } }; - await runLoop(gs.loop, scope, engine, over, yieldFn); + await runLoop(loopExpr, scope, engine, over, yieldFn); return val(engine.registry.void(), undefined); } @@ -158,9 +200,16 @@ export class LoopExpr extends Expr { validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { const overT = p.at('over', () => walkValidate(engine, this.over, scope, p, ctx)); const gs = overT.get(); - if (!gs?.loop) { + // Iterable: type's GetSet defines either a `loop` ExprDef + // (static — iterated once) or `loopDynamic: true` (re-evaluated + // per iteration; bool uses this for while-loop semantics). + const iterable = !!(gs?.loop || gs?.loopDynamic); + if (!iterable) { p.error('loop.not-iterable', `type '${overT.name}' has no loop defined`); } + if (gs?.loopDynamic && this.parallel) { + p.error('loop.parallel.dynamic', 'parallel options (concurrent / rate) are not meaningful for a dynamic (re-evaluated) loop'); + } // parallel.concurrent must be num; parallel.rate must be num or duration. if (this.parallel?.concurrent) { @@ -196,9 +245,11 @@ export class LoopExpr extends Expr { p.at('value', () => checkBindingName(this.valueName!, scope, p)); } - // Bind key/value using the iterable's actual types (not any) so the - // body validates against correct inner types. Fall back to any only - // when the iterable surface was missing (already errored above). + // Bind key/value from the iterable's GetSet. Both static and + // dynamic modes share the same `gs.key` / `gs.value` types — for + // bool that's `num{whole,min:0}` / `bool`; for list it's + // `num{whole,min:0}` / ``. Fall back to `any` only when + // the iterable surface was missing (already errored above). const keyType = gs?.key ?? engine.registry.any(); const valueType = gs?.value ?? engine.registry.any(); const child: TypeScope = new Map(scope); diff --git a/packages/gin/src/extension.ts b/packages/gin/src/extension.ts index b5c2c34..c433b2d 100644 --- a/packages/gin/src/extension.ts +++ b/packages/gin/src/extension.ts @@ -11,12 +11,7 @@ import { type Rnd, Type, } from './type'; -import { - encodeCall, - encodeGetSet, - encodeInit, - encodeProps, -} from './spec'; +import { encodeProps } from './spec'; import type { Scope } from './scope'; import type { Engine } from './engine'; import type { JSONOf, RuntimeOf } from './json-type'; @@ -262,9 +257,9 @@ export class Extension extends Type { generic: Object.keys(mergedGeneric).length > 0 ? mergedGeneric : undefined, options: mergedOptions && Object.keys(mergedOptions).length > 0 ? mergedOptions : undefined, props: this.local.props ? encodeProps(this.local.props) : undefined, - get: this.local.get ? encodeGetSet(this.local.get) : undefined, - call: this.local.call ? encodeCall(this.local.call) : undefined, - init: this.local.init ? encodeInit(this.local.init) : undefined, + get: this.local.get?.toJSON(), + call: this.local.call?.toJSON(), + init: this.local.init?.toJSON(), constraint: this.local.constraint ? this.local.constraint.toJSON() : undefined, }; } diff --git a/packages/gin/src/schema.ts b/packages/gin/src/schema.ts index 3a8348e..e54196c 100644 --- a/packages/gin/src/schema.ts +++ b/packages/gin/src/schema.ts @@ -42,10 +42,32 @@ export interface GetSetDef { get?: ExprDef; set?: ExprDef; loop?: ExprDef; + /** + * When true, `LoopExpr` re-evaluates `over` BEFORE every iteration + * and binds the resulting value to `value` (and the iteration index + * to `key`). The loop continues while `value.raw` is truthy and + * exits when it becomes falsy. With this flag the type does NOT + * need a `loop` native — gin's loop machinery iterates directly. + * + * Bool sets this to get while-loop semantics. Other types can opt + * in with whatever truthy semantic makes sense for their `raw` + * (optional → present, num → non-zero, etc.). + */ + loopDynamic?: boolean; } export interface CallDef { docs?: string; + /** + * Local type aliases scoped to THIS call. Each entry is a TypeDef + * referenced inside `args` / `returns` / `throws` / `get` / `set` via + * a bare `{name: ''}` reference. Aliases process AFTER + * the parent type's generics (so they may reference generic + * placeholders) and BEFORE the call slots (so the slots resolve + * against them). Sequential — later aliases may reference earlier. + * Inlining happens at parse time inside `decodeCall`. + */ + types?: Record; args: TypeDef; returns?: TypeDef; throws?: TypeDef; diff --git a/packages/gin/src/schemas.ts b/packages/gin/src/schemas.ts index 07ba212..005301c 100644 --- a/packages/gin/src/schemas.ts +++ b/packages/gin/src/schemas.ts @@ -80,6 +80,12 @@ export function getSetDefSchema(opts: SchemaOptions): z.ZodTypeAny { get: opts.Expr.optional(), set: opts.Expr.optional(), loop: opts.Expr.optional(), + loopDynamic: z + .boolean() + .optional() + .describe( + 'When true, `loop over: ` re-evaluates the expression each iteration and exits when the result\'s raw is falsy. Bool uses this for while-loop semantics. The type may have either `loop` (for static iterables) OR `loopDynamic` set; with loopDynamic, no `loop` ExprDef is required.', + ), }).meta({ aid: 'GetSetDef' }); } @@ -87,6 +93,14 @@ export function getSetDefSchema(opts: SchemaOptions): z.ZodTypeAny { export function callDefSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ docs: z.string().optional(), + types: z + .record(z.string(), opts.Type) + .optional() + .describe( + 'Call-local type aliases. Declare reusable named types here ONCE and reference them inside `args` / `returns` / `throws` / `get` / `set` as a bare `{name: ""}`. ' + + 'Aliases process AFTER any enclosing generics (so they may reference generic placeholders) and BEFORE the call slots — the call slots resolve them at parse time. ' + + 'Sequential: later aliases may reference earlier ones; forward / self references throw. Use this whenever the same composite type appears more than once in a signature — instead of writing `num{whole:true, min:1}` four times, declare `{ counter: { name:"num", options:{whole:true,min:1} } }` once and reference `{name:"counter"}`.', + ), args: opts.Type, returns: opts.Type.optional(), throws: opts.Type.optional(), diff --git a/packages/gin/src/spec.ts b/packages/gin/src/spec.ts index d73d9e7..71a7eb2 100644 --- a/packages/gin/src/spec.ts +++ b/packages/gin/src/spec.ts @@ -1,6 +1,7 @@ import type { Registry } from './registry'; import { Call, GetSet, Init, Prop, type PropSpec, type Type } from './type'; import type { CallDef, GetSetDef, PropDef, TypeDef } from './schema'; +import { buildAliasMap, inlineCallDef } from './exprs/inline-aliases'; // ─── generic substitution (TypeDef tree) ───────────────────────────────── @@ -61,12 +62,23 @@ export function substituteChildren( } if (def.call) { + // If the source CallDef declared `types` (call-local aliases), + // first inline them so substituteTypeDef doesn't try to + // registry.parse a bare alias-name and throw. Then drop `types` + // from the substituted output — bound calls are alias-free in + // both their parsed and JSON forms (Plan-agent footgun fix). + const callBase = def.call.types + ? inlineCallDef(def.call, buildAliasMap(def.call.types, registry)) + : def.call; next.call = { - ...def.call, - args: substituteTypeDef(def.call.args, bindings, registry), - returns: def.call.returns ? substituteTypeDef(def.call.returns, bindings, registry) : undefined, - throws: def.call.throws ? substituteTypeDef(def.call.throws, bindings, registry) : undefined, + ...callBase, + args: substituteTypeDef(callBase.args, bindings, registry), + returns: callBase.returns ? substituteTypeDef(callBase.returns, bindings, registry) : undefined, + throws: callBase.throws ? substituteTypeDef(callBase.throws, bindings, registry) : undefined, }; + // `types` was either absent or already consumed by the inline + // pass — either way, the substituted output should not carry it. + delete (next.call as { types?: unknown }).types; } if (def.init) { @@ -79,57 +91,36 @@ export function substituteChildren( /** * Runtime ↔ schema conversion for Prop/GetSet/Call/Init specs. * Runtime specs hold resolved Type instances; schema specs hold TypeDef JSON. - * Each concrete Type uses these when implementing encode() and parse/from. + * + * **Encoding lives on the runtime classes** as `toJSON()` methods — + * `Prop.toJSON()`, `GetSet.toJSON()`, `Call.toJSON()`, `Init.toJSON()`. + * Each concrete Type calls `.toJSON()` directly when implementing its + * own `toJSON()`. The free `encodeProps` helper below is the only + * survivor: it's a thin map-shim that normalizes `PropSpec`s to + * `Prop` instances before calling `.toJSON()`. + * + * Decoding (the reverse — JSON → runtime) lives here as free functions + * because each decode needs the registry to recurse into child types, + * and putting them as static methods on the runtime classes would mean + * every runtime class importing the registry. */ // ─── encode (runtime → schema) ──────────────────────────────────────────── -export function encodeProp(prop: Prop | PropSpec): PropDef { - return { - docs: prop.docs, - type: prop.type.toJSON(), - get: prop.get, - default: prop.default, - set: prop.set, - }; -} - +/** + * Map a record of Prop/PropSpec values to their JSON form. Normalizes + * each entry through `Prop.from` so `PropSpec` plain objects work + * alongside `Prop` instances. The only free encode function — the + * single-instance ones live as methods on the runtime classes. + */ export function encodeProps(props: Record): Record { const out: Record = {}; - for (const [name, prop] of Object.entries(props)) out[name] = encodeProp(prop); + for (const [name, prop] of Object.entries(props)) { + out[name] = Prop.from(prop).toJSON(); + } return out; } -export function encodeGetSet(gs: GetSet): GetSetDef { - return { - docs: gs.docs, - key: gs.key.toJSON(), - value: gs.value.toJSON(), - get: gs.get, - set: gs.set, - loop: gs.loop, - }; -} - -export function encodeCall(call: Call): CallDef { - return { - docs: call.docs, - args: call.args.toJSON(), - returns: call.returns?.toJSON(), - throws: call.throws?.toJSON(), - get: call.get, - set: call.set, - }; -} - -export function encodeInit(init: Init): NonNullable { - return { - docs: init.docs, - args: init.args.toJSON(), - run: init.run, - }; -} - // ─── decode (schema → runtime), recurses via registry ──────────────────── export function decodeProp(def: PropDef, registry: Registry): Prop { @@ -155,18 +146,33 @@ export function decodeGetSet(def: GetSetDef, registry: Registry): GetSet { get: def.get, set: def.set, loop: def.loop, + loopDynamic: def.loopDynamic, docs: def.docs, }); } export function decodeCall(def: CallDef, registry: Registry): Call { + // Build the alias source map (sequential — later may reference + // earlier). When `def.types` is undefined this is a no-op map and + // the inliner pass-through returns the slots unchanged. + const aliases = buildAliasMap(def.types, registry); + const hasAliases = Object.keys(aliases).length > 0; + const inlined = hasAliases ? inlineCallDef(def, aliases) : def; + return new Call({ - args: registry.parse(def.args) as Type, - returns: def.returns ? registry.parse(def.returns) : undefined, - throws: def.throws ? registry.parse(def.throws) : undefined, - get: def.get, - set: def.set, + args: registry.parse(inlined.args) as Type, + returns: inlined.returns ? registry.parse(inlined.returns) : undefined, + throws: inlined.throws ? registry.parse(inlined.throws) : undefined, + get: inlined.get, + set: inlined.set, docs: def.docs, + // Source-form preservation, only when aliases were actually used. + types: hasAliases ? aliases : undefined, + sourceArgs: hasAliases ? def.args : undefined, + sourceReturns: hasAliases ? def.returns : undefined, + sourceThrows: hasAliases ? def.throws : undefined, + sourceGet: hasAliases ? def.get : undefined, + sourceSet: hasAliases ? def.set : undefined, }); } diff --git a/packages/gin/src/type-scope.ts b/packages/gin/src/type-scope.ts new file mode 100644 index 0000000..411ba97 --- /dev/null +++ b/packages/gin/src/type-scope.ts @@ -0,0 +1,69 @@ +import type { Registry } from './registry'; +import type { Type } from './type'; + +/** + * Type-name resolution scope. A tree of name → Type bindings rooted at + * the Registry. Used by `AliasType` to resolve `{name: 'X'}` lazily, + * and by `Registry.parse` to dispatch bare-name TypeDefs to AliasType + * when X is bound in a local scope. + * + * - The Registry is the root scope; it implements `Scope` directly. + * Its `lookup` walks `namedTypes` and built-in `classes`. + * - `LocalScope` wraps a parent scope with an overlay map. Used by + * `decodeCall` to scope `CallDef.types` aliases, by FnType to scope + * declared generics, etc. + * + * Distinct from: + * - `Scope` in `scope.ts` (runtime variable bindings — Value scope). + * - `TypeScope` in `analysis.ts` (`Map` for static + * variable-type analysis during validate / typeOf). + */ +export interface Scope { + /** Look up a type by name. Returns the bound Type if present in this + * scope or any parent scope; undefined if not found anywhere. */ + lookup(name: string): Type | undefined; + + /** The root Registry — every Scope can resolve to it via the parent + * chain. Used by Type subclasses that need to construct child types + * (e.g. `this.scope.registry.num()`) without caring about whether + * they're inside a LocalScope. */ + readonly registry: Registry; + + /** Parent scope, or undefined for the root (Registry). */ + readonly parent?: Scope; +} + +/** + * A scope layer holding local name → Type bindings. Falls through to + * `parent.lookup` on miss. Construction order matters for sequential + * builds (later aliases referencing earlier ones); the caller is + * responsible for adding bindings in order if dependencies exist. + */ +export class LocalScope implements Scope { + readonly parent: Scope; + readonly registry: Registry; + private readonly local: Record; + + constructor(parent: Scope, local: Record = {}) { + this.parent = parent; + this.registry = parent.registry; + this.local = local; + } + + lookup(name: string): Type | undefined { + return this.local[name] ?? this.parent.lookup(name); + } + + /** Add a binding to this scope's local map. Used by sequential + * alias / generic build steps where each entry may reference + * earlier ones. */ + bind(name: string, type: Type): void { + this.local[name] = type; + } + + /** Return the names bound DIRECTLY in this scope (excluding parent). + * Used for diagnostics / rendering. */ + ownNames(): string[] { + return Object.keys(this.local); + } +} diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index ea09aed..fe1e783 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -1,5 +1,5 @@ import type { Registry } from './registry'; -import type { ExprDef, TypeDef, PathDef, PathStepDef } from './schema'; +import type { ExprDef, TypeDef, PathDef, PathStepDef, PropDef, GetSetDef, CallDef } from './schema'; import type { Expr } from './expr'; import { Value, val } from './value'; import { substituteChildren } from './spec'; @@ -56,6 +56,17 @@ export class Prop { return x instanceof Prop ? x : new Prop(x); } + /** Serialize to PropDef JSON. Inverse of `decodeProp` in spec.ts. */ + toJSON(): PropDef { + return { + docs: this.docs, + type: this.type.toJSON(), + get: this.get, + default: this.default, + set: this.set, + }; + } + // ─── runtime ops (called by Path.walk) ───────────────────────────────── /** Read this prop on `self`: evaluate get Expr with {this, super?}, or @@ -147,6 +158,9 @@ export class GetSet { readonly get?: ExprDef; readonly set?: ExprDef; readonly loop?: ExprDef; + /** When true, `LoopExpr` re-evaluates `over` each iteration and + * exits on falsy `raw`. See `GetSetDef.loopDynamic`. */ + readonly loopDynamic?: boolean; readonly docs?: string; constructor(spec: { @@ -155,6 +169,7 @@ export class GetSet { get?: ExprDef; set?: ExprDef; loop?: ExprDef; + loopDynamic?: boolean; docs?: string; }) { this.key = spec.key; @@ -162,9 +177,23 @@ export class GetSet { this.get = spec.get; this.set = spec.set; this.loop = spec.loop; + this.loopDynamic = spec.loopDynamic; this.docs = spec.docs; } + /** Serialize to GetSetDef JSON. Inverse of `decodeGetSet` in spec.ts. */ + toJSON(): GetSetDef { + return { + docs: this.docs, + key: this.key.toJSON(), + value: this.value.toJSON(), + get: this.get, + set: this.set, + loop: this.loop, + loopDynamic: this.loopDynamic, + }; + } + /** Read this[key]: runs get Expr with {this, key, super?}. */ async indexRead(self: Value, keyValue: Value, scope: Scope, engine: Engine): Promise { if (!this.get) throw new Error(`path: type '${self.type.name}' has no index get`); @@ -186,6 +215,21 @@ export class GetSet { /** * Runtime Call — callable spec, with arg/return/throws Types resolved. + * + * The parsed `args` / `returns` / `throws` (and `get` / `set` ExprDefs + * that flow through to the engine) are the fully-inlined forms — the + * runtime / analysis layer never sees alias references. + * + * When the source `CallDef` declared `types` (call-local type aliases), + * the un-inlined source forms are preserved on private fields below + * so `toJSON()` can emit the compact aliased shape rather than the + * verbose inlined one. These fields are populated only when aliases + * were actually used; otherwise undefined. + * + * On `.bind()` substitution, gin's substitute pipeline drops the + * `types` and source fields so post-bind `toJSON()` doesn't emit a + * stale source map alongside an updated parsed call. The bound Call + * is therefore alias-free in both representations. */ export class Call { readonly args: Type; @@ -195,6 +239,20 @@ export class Call { readonly set?: ExprDef; readonly docs?: string; + /** Call-local type aliases declared on `CallDef.types`. Public so + * rendering (toCode / toCodeDefinition) can surface the alias + * header. Populated only when aliases were used; undefined + * otherwise. */ + readonly types?: Record; + // Un-inlined source forms for round-trip via `toJSON()`. Private — + // pure bookkeeping, no external consumer. Populated alongside + // `types`; undefined when no aliases were declared. + private readonly sourceArgs?: TypeDef; + private readonly sourceReturns?: TypeDef; + private readonly sourceThrows?: TypeDef; + private readonly sourceGet?: ExprDef; + private readonly sourceSet?: ExprDef; + constructor(spec: { args: Type; returns?: Type; @@ -202,6 +260,12 @@ export class Call { get?: ExprDef; set?: ExprDef; docs?: string; + types?: Record; + sourceArgs?: TypeDef; + sourceReturns?: TypeDef; + sourceThrows?: TypeDef; + sourceGet?: ExprDef; + sourceSet?: ExprDef; }) { this.args = spec.args; this.returns = spec.returns; @@ -209,6 +273,37 @@ export class Call { this.get = spec.get; this.set = spec.set; this.docs = spec.docs; + this.types = spec.types; + this.sourceArgs = spec.sourceArgs; + this.sourceReturns = spec.sourceReturns; + this.sourceThrows = spec.sourceThrows; + this.sourceGet = spec.sourceGet; + this.sourceSet = spec.sourceSet; + } + + /** Serialize to CallDef JSON. Inverse of `decodeCall` in spec.ts. */ + toJSON(): CallDef { + if (this.types) { + // Source-form preservation: emit the un-inlined slots so the + // saved CallDef stays compact (alias names intact). + return { + docs: this.docs, + types: this.types, + args: this.sourceArgs ?? this.args.toJSON(), + returns: this.sourceReturns ?? this.returns?.toJSON(), + throws: this.sourceThrows ?? this.throws?.toJSON(), + get: this.sourceGet ?? this.get, + set: this.sourceSet ?? this.set, + }; + } + return { + docs: this.docs, + args: this.args.toJSON(), + returns: this.returns?.toJSON(), + throws: this.throws?.toJSON(), + get: this.get, + set: this.set, + }; } } @@ -225,6 +320,15 @@ export class Init { this.run = spec.run; this.docs = spec.docs; } + + /** Serialize to InitDef JSON. Inverse of `decodeInit` in spec.ts. */ + toJSON(): NonNullable { + return { + docs: this.docs, + args: this.args.toJSON(), + run: this.run, + }; + } } // ============================================================================ @@ -697,6 +801,20 @@ export abstract class Type implements Node { toCodeDefinition(): string { const lines: string[] = []; + // Call-local type aliases — rendered first so they read like + // class-level type-alias declarations and can be referenced when + // reading the constructor / call signature lines below. + const call = this.definitionCall(); + if (call?.types) { + for (const [name, def] of Object.entries(call.types)) { + try { + lines.push(` type ${name} = ${this.registry.parse(def).toCode()};`); + } catch { + lines.push(` type ${name} = ${JSON.stringify(def)};`); + } + } + } + // Constructor — rendered first so the shape reads like a class. const init = this.definitionInit(); if (init) { @@ -705,7 +823,6 @@ export abstract class Type implements Node { } // Call signature (`fn` / iface with call / Extension with call). - const call = this.definitionCall(); if (call) { const ret = call.returns?.toCode() ?? 'void'; lines.push(` (${formatParams(call.args)}): ${ret}`); @@ -831,6 +948,27 @@ export function optionsCode(opts: object | undefined | null): string { return `{${parts.join(', ')}}`; } +/** + * Render a Call's `types` (call-local type aliases) as a header block + * `{a: ; b: }` immediately after the generic params and + * before the parameter list. Each alias is parsed in isolation so its + * generic-placeholder references render as `T` etc. Empty / missing + * map → empty string. + */ +export function renderCallTypes( + registry: { parse(def: TypeDef): Type }, + types: Record | undefined, +): string { + if (!types) return ''; + const keys = Object.keys(types); + if (keys.length === 0) return ''; + const parts = keys.map((k) => { + try { return `${k}: ${registry.parse(types[k]!).toCode()}`; } + catch { return `${k}: ${JSON.stringify(types[k])}`; } + }); + return `{${parts.join('; ')}}`; +} + /** * Render a type's generic-parameter map as ``. `T` when bound * is `any` (unconstrained) or a self-referencing GenericType placeholder, diff --git a/packages/gin/src/types/bool.ts b/packages/gin/src/types/bool.ts index 8a73597..3431418 100644 --- a/packages/gin/src/types/bool.ts +++ b/packages/gin/src/types/bool.ts @@ -1,7 +1,7 @@ import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; -import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; +import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { BoolOptions } from '../builder'; import { z } from 'zod'; import type { SchemaOptions, ValueSchemaOptions } from '../node'; @@ -88,6 +88,24 @@ export class BoolType extends Type { }; } + /** + * Bool opts into `LoopExpr`'s while-loop semantics via + * `loopDynamic: true`. The loop's `over` expression is re-evaluated + * each iteration; iteration continues while the resulting bool is + * `true` and exits when it flips to `false`. The body sees `key` + * (iteration index, num) and `value` (current bool truth-value). + * No `loop` ExprDef is required — the engine drives iteration + * directly. + */ + get(): GetSet { + const r = this.registry; + return new GetSet({ + key: r.num({ whole: true, min: 0 }), + value: r.bool(), + loopDynamic: true, + }); + } + toJSON(): TypeDef { return { name: BoolType.NAME, diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 6b0a110..0dba7ca 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -1,8 +1,8 @@ import type { Registry } from '../registry'; import type { ExprDef, TypeDef } from '../schema'; import { Value } from '../value'; -import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderGenerics } from '../type'; -import { decodeCall, encodeCall } from '../spec'; +import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderCallTypes, renderGenerics } from '../type'; +import { decodeCall } from '../spec'; import { z } from 'zod'; import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema } from '../schemas'; @@ -149,7 +149,7 @@ export class FnType extends Type> { : Object.fromEntries(genericKeys.map((k) => [k, this.generic[k]!.toJSON()])); return { name: FnType.NAME, - call: encodeCall(this._call), + call: this._call.toJSON(), generic, }; } @@ -173,7 +173,7 @@ export class FnType extends Type> { toCode(): string { const ret = this._call.returns?.toCode() ?? 'void'; return this.docsPrefix() - + `${renderGenerics(this.generic)}(${formatParams(this._call.args)}): ${ret}`; + + `${renderGenerics(this.generic)}${renderCallTypes(this.registry, this._call.types)}(${formatParams(this._call.args)}): ${ret}`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/iface.ts b/packages/gin/src/types/iface.ts index 2a6b511..42d15d2 100644 --- a/packages/gin/src/types/iface.ts +++ b/packages/gin/src/types/iface.ts @@ -10,7 +10,7 @@ import { type Rnd, Type, } from '../type'; -import { decodeCall, decodeGetSet, decodeProps, encodeCall, encodeGetSet, encodeProps } from '../spec'; +import { decodeCall, decodeGetSet, decodeProps, encodeProps } from '../spec'; import { z } from 'zod'; import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema, getSetDefSchema, propDefSchema } from '../schemas'; @@ -187,8 +187,8 @@ export class IfaceType extends Type> { return { name: IfaceType.NAME, props: Object.keys(this._props).length > 0 ? encodeProps(this._props) : undefined, - get: this._get ? encodeGetSet(this._get) : undefined, - call: this._call ? encodeCall(this._call) : undefined, + get: this._get?.toJSON(), + call: this._call?.toJSON(), }; } diff --git a/packages/ginny/src/context.ts b/packages/ginny/src/context.ts index 6e1f945..be952be 100644 --- a/packages/ginny/src/context.ts +++ b/packages/ginny/src/context.ts @@ -1,4 +1,4 @@ -import type { Registry, Engine, Type, Value, ObjType } from '@aeye/gin'; +import type { Registry, Engine, Type, TypeDef, Value, ObjType } from '@aeye/gin'; import type { Store } from './store'; import type { RunState } from './run-state'; @@ -44,7 +44,25 @@ export interface Ctx { * narrowing checks, and forces `engineer.create_new_fn` to validate * the input up front. */ - targetFn?: { name: string; argsType: ObjType; returnsType: Type }; + targetFn?: { + name: string; + /** Parsed (and alias-inlined) args type — used by `test()` to wrap + * raw scope args via `argsType.parse(rawArgs)` and by `write()` + * to bind `args` in the validate scope. */ + argsType: ObjType; + /** Parsed (and alias-inlined) returns type — used by validate / + * static analysis. */ + returnsType: Type; + /** + * Optional source forms for round-trip preservation when the + * engineer declared `call.types` aliases. `finish()` writes these + * back verbatim so the saved fn keeps its compact shape; without + * them, `argsType.toJSON()` would emit the verbose inlined form. + */ + callTypes?: Record; + sourceArgs?: TypeDef; + sourceReturns?: TypeDef; + }; } /** Hard cap on programmer recursion. With 0-indexed depth, programmers diff --git a/packages/ginny/src/prompts/engineer.ts b/packages/ginny/src/prompts/engineer.ts index c62bca5..53cb3ab 100644 --- a/packages/ginny/src/prompts/engineer.ts +++ b/packages/ginny/src/prompts/engineer.ts @@ -8,6 +8,12 @@ import { ask } from '../tools/ask'; import { runSubagent } from '../progress'; import { MAX_PROGRAMMER_DEPTH } from '../context'; import { createRunState } from '../run-state'; +// `programmer` and `engineer` form a circular import (programmer ↔ +// findOrCreateFunctions → engineer → createNewFn → programmer). The +// reference here is only used inside `createNewFn`'s `call` async fn, +// so by call-time both modules have finished initializing — ESM live +// bindings make this safe. +import { programmer } from './programmer'; const searchFns = ai.tool({ name: 'search_fns', @@ -56,15 +62,26 @@ const createNewFn = ai.tool({ return z.object({ name: z.string().describe('Unique function name (camelCase)'), description: z.string().describe('What the function should do'), + types: z + .record(z.string(), opts.Type as z.ZodType) + .optional() + .describe( + 'Optional `call.types` aliases — declare reusable named types here ONCE and reference them inside `args` / `returns` as a bare `{name: ""}`. ' + + 'Use whenever the same composite type would appear more than once in the signature. ' + + 'Example: `{ "positiveInt": { "name": "num", "options": { "whole": true, "min": 1 } } }` lets you write `args: { name: "obj", props: { n: { type: { name: "positiveInt" } } } }` and `returns: { name: "list", generic: { V: { name: "positiveInt" } } }` — instead of repeating the full options block twice. ' + + 'Sequential: later aliases may reference earlier. Forward / self references throw. Alias names cannot match a built-in type-class name (`num`, `list`, `obj`, etc.).', + ), args: (opts.Type as z.ZodType).describe( 'TypeDef of the function\'s parameter object. The PROPS of this obj ARE the function\'s parameters — ' + 'each prop becomes a scope variable in the body when the function is called. ' + 'Examples: function taking one number → `{ name: "obj", props: { n: { type: { name: "num" } } } }`; ' + 'function taking text and a list → `{ name: "obj", props: { name: { type: { name: "text" } }, items: { type: { name: "list", generic: { V: { name: "any" } } } } } }`. ' + - 'Only use `{ name: "obj" }` (empty obj, no props) for genuinely nullary functions — most useful functions have parameters, so default to listing them as props.', + 'Only use `{ name: "obj" }` (empty obj, no props) for genuinely nullary functions — most useful functions have parameters, so default to listing them as props. ' + + 'May reference any alias declared in `types` via `{name: ""}` (saves repetition).', ), returns: (opts.Type as z.ZodType).describe( - 'TypeDef of the function\'s return value — e.g. `{ name: "list", generic: { V: { name: "num" } } }` for `list`.', + 'TypeDef of the function\'s return value — e.g. `{ name: "list", generic: { V: { name: "num" } } }` for `list`. ' + + 'May reference any alias declared in `types` via `{name: ""}`.', ), }); }, @@ -73,32 +90,49 @@ const createNewFn = ai.tool({ // got us here. applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, call: async ( - input: { name: string; description: string; args: TypeDef; returns: TypeDef }, + input: { + name: string; + description: string; + args: TypeDef; + returns: TypeDef; + types?: Record; + }, _refs, ctx, ) => { - const { programmer } = await import('./programmer'); - - // Parse the engineer-supplied signature into runtime Types — these - // are what `test()` uses to wrap raw scope args and what `finish()` - // saves as the function's persisted type. + // Parse the engineer-supplied signature into runtime Types. When + // the engineer declared `types` aliases, args/returns may + // reference them — we resolve those by parsing through a synthetic + // FnType TypeDef (which routes through `decodeCall`'s alias + // inliner). This makes the engineer's declared aliases live for + // both the targetFn pass-through AND the eventual saved fn. let argsType: ObjType; let returnsType: Type; try { - const parsedArgs = ctx.registry.parse(input.args); - if (!(parsedArgs instanceof ObjType)) { - throw new Error(`expected an obj type, got '${parsedArgs.name}'`); + const fnDef: TypeDef = { + name: 'function', + call: { + ...(input.types ? { types: input.types } : {}), + args: input.args, + returns: input.returns, + }, + }; + const parsedFn = ctx.registry.parse(fnDef); + const parsedCall = (parsedFn as { _call?: { args: Type; returns?: Type } })._call; + if (!parsedCall) throw new Error('parsed FnType has no call spec'); + if (!(parsedCall.args instanceof ObjType)) { + throw new Error(`expected args to be an obj type, got '${parsedCall.args.name}'`); } - argsType = parsedArgs; + argsType = parsedCall.args; + if (!parsedCall.returns) throw new Error('returns is required'); + returnsType = parsedCall.returns; } catch (e: unknown) { throw new ToolInterrupt( - `Could not parse args type for '${input.name}': ${e instanceof Error ? e.message : String(e)}. ` + - `args must be an obj type whose props are the function's parameters — e.g. \`{ name: "obj", props: { n: { type: { name: "num" } } } }\`.`, + `Could not parse signature for '${input.name}': ${e instanceof Error ? e.message : String(e)}. ` + + `args must be an obj type whose props are the function's parameters — e.g. \`{ name: "obj", props: { n: { type: { name: "num" } } } }\`. ` + + `If you declared \`types\` aliases, ensure each is declared before it's referenced and that referenced names are bare \`{name: ""}\`.`, ); } - try { returnsType = ctx.registry.parse(input.returns); } catch (e: unknown) { - throw new ToolInterrupt(`Could not parse returns type for '${input.name}': ${e instanceof Error ? e.message : String(e)}`); - } const argsCode = (() => { try { return argsType.toCode(); } catch { return JSON.stringify(input.args); } })(); const returnsCode = (() => { try { return returnsType.toCode(); } catch { return JSON.stringify(input.returns); } })(); @@ -161,7 +195,18 @@ const createNewFn = ai.tool({ messages, programmerDepth: childDepth, runState: innerRunState, - targetFn: { name: input.name, argsType, returnsType }, + targetFn: { + name: input.name, + argsType, + returnsType, + ...(input.types + ? { + callTypes: input.types, + sourceArgs: input.args, + sourceReturns: input.returns, + } + : {}), + }, }; await runSubagent( diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index 955c79e..1d15e6c 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -291,6 +291,56 @@ When you delegate to \`find_or_create_functions\`, spell out which inputs are user-supplied (parameters) versus fixed in the description. The engineer uses your description verbatim to design the signature. +## When \`find_or_create_functions\` fails + +If \`find_or_create_functions\` returns a message starting with +\`// FAILED\` (or otherwise indicates no functions were loaded), it +means the engineer could not produce the function — typically because +the inner programmer never reached a passing test, or because no +existing saved fn matched the keywords. + +When this happens: +- Do NOT inline-define the missing function (no + \`define myFn = lambda(...)\` as part of your draft). Inline-defining + a recursive / loop-heavy function in gin without going through the + engineer's iteration is fragile and almost always produces invalid + programs. +- Respond to the user that the function couldn't be created, explain + briefly what likely went wrong, and ask whether they want to: + (a) clarify the signature (simpler args / returns), + (b) reduce the scope of what the function should do, or + (c) try a different approach altogether. +- Then stop. Do not call write / test for an inline workaround. + +## Common gotchas + +- **\`loop.over\` modes — iterable vs. bool while-loop.** When + \`over\` evaluates to a list / map / num / text (anything with + \`get().loop\`), the expression is evaluated ONCE and the loop walks + the resulting iterable. When \`over\` evaluates to a **bool**, the + expression is RE-EVALUATED each iteration — true while-loop + semantics: the loop continues while the value is \`true\` and exits + the moment it becomes \`false\`. Inside the body, \`key\` is the + iteration index (num) and \`value\` is the bool's truth-value. + Combine with \`flow:break\` / \`flow:continue\` for explicit early + exit. For state that evolves across iterations, use \`set\` exprs in + the body to mutate the variables the bool expression reads. +- **Function types are \`{name: 'function', call: {args, returns}}\`.** + Do NOT invent obj shapes with a \`returns\` key as a fn type. If + you find yourself writing \`type: { args: ..., returns: ... }\` + without \`name: 'function'\`, that's wrong. +- **Mutating a local var is a \`set\` expr.** Use \`{ kind: "set", + path: [{prop: "varName"}], value: }\`. Never write + \`varName = ...\` — that's TypeScript syntax, not a gin ExprDef. +- **Method args use the parameter name from the type's definition.** + E.g. \`num.mod\` takes \`{ other: }\`, NOT \`{ value: ... }\`. + Read the method's def in the type catalog above; \`mod(other: num): + num\` means the call args obj has key \`other\`. +- **Don't redeclare a function inline after asking + \`find_or_create_functions\` for it.** Either the engineer succeeded + (use the saved fn directly via \`{name}({...args})\`) or it failed + (escalate per the section above). + ## Workflow 1. If the task needs types / fns / vars not in scope, call diff --git a/packages/ginny/src/tools/find-or-create-fns.ts b/packages/ginny/src/tools/find-or-create-fns.ts index 3d3f2f8..d1d4450 100644 --- a/packages/ginny/src/tools/find-or-create-fns.ts +++ b/packages/ginny/src/tools/find-or-create-fns.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ai } from '../ai'; +import { engineer } from '../prompts/engineer'; import { runSubagent } from '../progress'; import { MAX_PROGRAMMER_DEPTH } from '../context'; @@ -22,7 +23,6 @@ export const findOrCreateFunctions = ai.tool({ // write/test/finish, which is what the user actually wants. applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, call: async (input: { description: string }, _refs, ctx) => { - const { engineer } = await import('../prompts/engineer'); const result = await runSubagent( `engineer: ${input.description}`, () => engineer.get('stream', { description: input.description }, ctx), @@ -62,14 +62,24 @@ export const findOrCreateFunctions = ai.tool({ } if (loaded.length === 0 && ghosts.length === 0) { - return 'No functions loaded.'; + return [ + '// FAILED: the engineer could not create or find any function for that description.', + '// Likely causes: the inner programmer never reached a passing test for the signature,', + '// or no existing saved fn matched the keywords.', + '//', + '// DO NOT inline-define the function in your draft (no `define myFn = lambda(...)` workaround).', + '// Instead: respond to the user that the function could not be created, briefly explain why', + '// it might have failed, and ask them to either (a) clarify the signature, (b) simplify the', + '// request, or (c) try a different approach. Then stop — do not call write/test.', + ].join('\n'); } const parts: string[] = []; if (loaded.length > 0) parts.push(loaded.join('\n')); if (ghosts.length > 0) { parts.push( `// Engineer claimed these were created but no file was written: ${ghosts.join(', ')}.\n` + - `// Treat them as NOT available — write your program inline or retry find_or_create_functions with a clearer description.`, + `// Treat them as NOT available — DO NOT inline-define them. Either retry find_or_create_functions\n` + + `// with a clearer description, or report the failure to the user.`, ); } return parts.join('\n\n'); diff --git a/packages/ginny/src/tools/find-or-create-types.ts b/packages/ginny/src/tools/find-or-create-types.ts index 5f36f8e..985065f 100644 --- a/packages/ginny/src/tools/find-or-create-types.ts +++ b/packages/ginny/src/tools/find-or-create-types.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { TypeDef } from '@aeye/gin'; import { ai } from '../ai'; +import { architect } from '../prompts/architect'; import { runSubagent } from '../progress'; interface ArchitectResult { @@ -16,7 +17,6 @@ export const findOrCreateTypes = ai.tool({ description: z.string().describe('What types are needed and why'), }), call: async (input: { description: string }, _refs, ctx) => { - const { architect } = await import('../prompts/architect'); const result = await runSubagent( `architect: ${input.description}`, () => architect.get('stream', { description: input.description }, ctx), diff --git a/packages/ginny/src/tools/find-or-create-vars.ts b/packages/ginny/src/tools/find-or-create-vars.ts index bb29789..d37721a 100644 --- a/packages/ginny/src/tools/find-or-create-vars.ts +++ b/packages/ginny/src/tools/find-or-create-vars.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ai } from '../ai'; +import { dba } from '../prompts/dba'; import { loadVarInto, refreshVarsGlobal } from '../vars-global'; import { runSubagent } from '../progress'; @@ -16,7 +17,6 @@ export const findOrCreateVars = ai.tool({ description: z.string().describe('What vars are needed and why'), }), call: async (input: { description: string }, _refs, ctx) => { - const { dba } = await import('../prompts/dba'); const result = await runSubagent( `dba: ${input.description}`, () => dba.get('stream', input, ctx), diff --git a/packages/ginny/src/tools/finish.ts b/packages/ginny/src/tools/finish.ts index 4d13727..17532fe 100644 --- a/packages/ginny/src/tools/finish.ts +++ b/packages/ginny/src/tools/finish.ts @@ -67,14 +67,29 @@ export const finish = ai.tool({ // Build the TypeDef with the body baked into `call.get`. Gin's // path walker invokes this directly — no ginny-side callable // wrapping needed. + // + // When the engineer declared `call.types` aliases, the parsed + // argsType / returnsType have those aliases ALREADY INLINED. + // Emitting the inlined toJSON would defeat the verbosity- + // reduction point. Use `targetFn.sourceArgs` / `sourceReturns` + // (the engineer's original input) so the saved fn keeps the + // alias references intact. + const useAliases = useTarget && ctx.targetFn?.callTypes && ctx.targetFn?.sourceArgs && ctx.targetFn?.sourceReturns; const fnTypeDef: TypeDef = { name: 'function', ...(input.docs ? { docs: input.docs } : {}), - call: { - args: argsType.toJSON(), - returns: returnsType.toJSON(), - get: draft, - }, + call: useAliases + ? { + types: ctx.targetFn!.callTypes, + args: ctx.targetFn!.sourceArgs!, + returns: ctx.targetFn!.sourceReturns!, + get: draft, + } + : { + args: argsType.toJSON(), + returns: returnsType.toJSON(), + get: draft, + }, }; try { diff --git a/packages/ginny/src/tools/research.ts b/packages/ginny/src/tools/research.ts index 876b1f1..a739a2f 100644 --- a/packages/ginny/src/tools/research.ts +++ b/packages/ginny/src/tools/research.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ai } from '../ai'; +import { researcher } from '../prompts/researcher'; import { runSubagent } from '../progress'; interface ResearchResult { @@ -22,7 +23,6 @@ export const research = ai.tool({ question: z.string().describe('The factual question to research'), }), call: async (input: { question: string }, _refs, ctx) => { - const { researcher } = await import('../prompts/researcher'); const result = await runSubagent( `researcher: ${input.question}`, () => researcher.get('stream', { question: input.question }, ctx), diff --git a/packages/ginny/src/tools/web-search.ts b/packages/ginny/src/tools/web-search.ts index 2eab0dd..0fdfe63 100644 --- a/packages/ginny/src/tools/web-search.ts +++ b/packages/ginny/src/tools/web-search.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { tavily } from '@tavily/core'; import { ai } from '../ai'; export const webSearch = ai.tool({ @@ -11,7 +12,6 @@ export const webSearch = ai.tool({ }), call: async (input: { query: string; maxResults?: number }) => { try { - const { tavily } = await import('@tavily/core'); const client = tavily({ apiKey: process.env['TAVILY_API_KEY']! }); const resp = await client.search( input.query, From ae9545aa22a6184579ffa6ccb369456e6bbe3e66 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Thu, 30 Apr 2026 15:11:37 -0400 Subject: [PATCH 04/21] Introduce AliasType and scope-based generic resolution Unify generics and bare-name references under a new AliasType and introduce scope-based resolution (LocalScope / TypeScope). Types now accept an optional scope parameter on resolution-touching APIs (valid, parse, encode, compatible, like, simplify, props/get/call/init, etc.) and AliasType.resolve consults the caller-supplied scope before its captured scope. The commit adds types/alias.ts, updates README to document the new AliasType/TypeScope model and simplify(), removes the old generic/ref substitution model, and updates numerous tests and internals to use r.alias(...) and LocalScope instead of bind/substitute. Several test expectations were adjusted to reflect lazy alias resolution and stable toJSON behavior (call-site bindings do not mutate source definitions). --- packages/gin/README.md | 107 +++-- .../gin/src/__tests__/binding-rules.test.ts | 10 +- .../src/__tests__/call-type-aliases.test.ts | 265 +++-------- packages/gin/src/__tests__/deep-set.test.ts | 2 +- .../gin/src/__tests__/extensibility.test.ts | 2 +- .../src/__tests__/extension-generics.test.ts | 155 +++--- .../gin/src/__tests__/gaps-generic.test.ts | 4 +- .../gin/src/__tests__/generic-methods.test.ts | 92 ++-- packages/gin/src/__tests__/generic.test.ts | 69 ++- packages/gin/src/__tests__/ginny-docs.test.ts | 4 +- .../gin/src/__tests__/recursive-types.test.ts | 42 +- packages/gin/src/__tests__/ref.test.ts | 44 +- packages/gin/src/__tests__/registry.test.ts | 15 +- packages/gin/src/__tests__/toCode.test.ts | 6 +- packages/gin/src/__tests__/typ-type.test.ts | 65 +-- packages/gin/src/analysis.ts | 10 +- packages/gin/src/builder.ts | 5 +- packages/gin/src/engine.ts | 10 +- packages/gin/src/expr.ts | 15 +- packages/gin/src/exprs/block.ts | 12 +- packages/gin/src/exprs/define.ts | 20 +- packages/gin/src/exprs/flow.ts | 14 +- packages/gin/src/exprs/get.ts | 11 +- packages/gin/src/exprs/if.ts | 16 +- packages/gin/src/exprs/inline-aliases.ts | 448 ------------------ packages/gin/src/exprs/lambda.ts | 77 ++- packages/gin/src/exprs/loop.ts | 20 +- packages/gin/src/exprs/native.ts | 11 +- packages/gin/src/exprs/new.ts | 11 +- packages/gin/src/exprs/set.ts | 11 +- packages/gin/src/exprs/switch.ts | 18 +- packages/gin/src/exprs/template.ts | 18 +- packages/gin/src/extension.ts | 51 +- packages/gin/src/path.ts | 110 +++-- packages/gin/src/registry.ts | 138 ++++-- packages/gin/src/schemas.ts | 2 +- packages/gin/src/spec.ts | 156 ++---- packages/gin/src/type-scope.ts | 54 ++- packages/gin/src/type.ts | 202 ++++---- packages/gin/src/types/alias.ts | 206 ++++++++ packages/gin/src/types/and.ts | 28 +- packages/gin/src/types/any.ts | 9 +- packages/gin/src/types/bool.ts | 7 +- packages/gin/src/types/color.ts | 7 +- packages/gin/src/types/date.ts | 7 +- packages/gin/src/types/duration.ts | 7 +- packages/gin/src/types/enum.ts | 31 +- packages/gin/src/types/fn.ts | 44 +- packages/gin/src/types/generic.ts | 115 ----- packages/gin/src/types/iface.ts | 49 +- packages/gin/src/types/index.ts | 3 +- packages/gin/src/types/list.ts | 33 +- packages/gin/src/types/literal.ts | 31 +- packages/gin/src/types/map.ts | 31 +- packages/gin/src/types/not.ts | 29 +- packages/gin/src/types/null.ts | 7 +- packages/gin/src/types/nullable.ts | 33 +- packages/gin/src/types/num.ts | 7 +- packages/gin/src/types/obj.ts | 27 +- packages/gin/src/types/optional.ts | 33 +- packages/gin/src/types/or.ts | 33 +- packages/gin/src/types/ref.ts | 158 ------ packages/gin/src/types/text.ts | 7 +- packages/gin/src/types/timestamp.ts | 7 +- packages/gin/src/types/tuple.ts | 31 +- packages/gin/src/types/typ.ts | 32 +- packages/gin/src/types/void.ts | 7 +- packages/ginny/src/ai.ts | 12 +- packages/ginny/src/consumer.ts | 398 ++++++++++++++++ packages/ginny/src/context.ts | 35 ++ packages/ginny/src/index.ts | 10 +- packages/ginny/src/logger.ts | 11 +- packages/ginny/src/natives/ask.ts | 116 +++++ packages/ginny/src/natives/fetch.ts | 4 +- packages/ginny/src/natives/llm.ts | 4 +- packages/ginny/src/natives/log.ts | 53 +++ packages/ginny/src/prompts/engineer.ts | 55 ++- packages/ginny/src/prompts/programmer.ts | 201 +++++++- packages/ginny/src/tools/test.ts | 9 +- 79 files changed, 2318 insertions(+), 1931 deletions(-) delete mode 100644 packages/gin/src/exprs/inline-aliases.ts create mode 100644 packages/gin/src/types/alias.ts delete mode 100644 packages/gin/src/types/generic.ts delete mode 100644 packages/gin/src/types/ref.ts create mode 100644 packages/ginny/src/consumer.ts create mode 100644 packages/ginny/src/natives/ask.ts create mode 100644 packages/ginny/src/natives/log.ts diff --git a/packages/gin/README.md b/packages/gin/README.md index af38f6f..fb3afbb 100644 --- a/packages/gin/README.md +++ b/packages/gin/README.md @@ -169,13 +169,13 @@ them. Every type implements: | Method | Purpose | |---|---| -| `valid(raw)` | Runtime type guard over the raw value | -| `parse(json)` | JSON → `Value` (throws on mismatch) | -| `encode(raw)` | raw → JSON envelope (round-trip-safe) | -| `compatible(other)` | structural compatibility check | -| `like(other)` | narrow self by `other`, recursing through children | -| `bind(bindings)` | substitute generic placeholders | -| `props()` / `get()` / `call()` / `init()` | expose fields, index access, call signatures, constructors | +| `valid(raw, scope?)` | Runtime type guard over the raw value | +| `parse(json, scope?)` | JSON → `Value` (throws on mismatch) | +| `encode(raw, scope?)` | raw → JSON envelope (round-trip-safe) | +| `compatible(other, opts?, scope?)` | structural compatibility check | +| `like(other, scope?)` | narrow self by `other`, recursing through children | +| `simplify(scope?)` | collapse trivial wrappers; AliasType resolves through `scope` | +| `props(scope?)` / `get(scope?)` / `call(scope?)` / `init(scope?)` | expose fields, index access, call signatures, constructors | | `toCode()` / `toCodeDefinition()` | render TypeScript-like source for the LLM | | `toSchema(opts)` | Zod schema for the TypeDef JSON | | `toValueSchema(opts)` | Zod schema for the runtime VALUE | @@ -308,38 +308,46 @@ An `Extension` wraps a base type, adds local options / fields / methods the base. Multi-level extension is supported — every layer's props compose. -### Recursive types - -`r.ref(name)` returns a lazy reference. The target doesn't need to -exist at construction time — resolve happens at use time, so mutual -cycles work: - -```ts -const Task = r.extend(r.obj({ - title: { type: r.text() }, - creator: { type: r.ref('User') }, -}), { name: 'Task' }); -r.register(Task); - -const User = r.extend(r.obj({ - name: { type: r.text() }, - tasks: { type: r.list(r.ref('Task')) }, -}), { name: 'User' }); -r.register(User); -``` - -### Generics - -Type-parameterized types (`list`, `map`, `typ`) store their -parameters in a `generic: Record` map on the base class. -`bind(bindings)` substitutes them through the whole subtree. Generic -placeholders pre-binding are maximally permissive. +### Recursive types & generics — both ride the same `AliasType` + +Bare-name TypeDefs (`{name: 'X'}` with no other peers) parse as +`AliasType('X')`. That single class covers what used to be two +distinct concepts: + +- **Lazy reference** to a named type registered with the registry — + the target doesn't need to exist at construction time, so mutual + cycles work: + + ```ts + const Task = r.extend(r.obj({ + title: { type: r.text() }, + creator: { type: r.alias('User') }, + }), { name: 'Task' }); + r.register(Task); + + const User = r.extend(r.obj({ + name: { type: r.text() }, + tasks: { type: r.list(r.alias('Task')) }, + }), { name: 'User' }); + r.register(User); + ``` + +- **Generic placeholder** — the same builder. Type-parameterized + types (`list`, `map`, `typ`) store their parameters in a + `generic: Record` map; the placeholders inside are + AliasTypes captured against the enclosing local scope. + +`AliasType.resolve(extra?)` walks an optional caller-supplied +TypeScope first, then its captured scope. That's the only resolution +mechanism — no `bind()` / `substitute()` / type-tree rebuilding. +Pre-resolution, an unresolved alias acts as a maximally permissive +placeholder. Function types support **method-level generics**: ```ts // list.map(fn: (value:V, index:num) => R): list -const listT = r.list(r.generic('V')); +const listT = r.list(r.alias('V')); listT.toCodeDefinition(); // type list { // map(fn: (value: V, index: num): R): list @@ -347,6 +355,33 @@ listT.toCodeDefinition(); // } ``` +#### Specializing generics at call sites — `TypeScope` + +Resolution-touching methods on `Type` (`parse`, `valid`, `compatible`, +`props`, `prop`, `get`, `call`, `init`, `follow`, `like`, `simplify`) +take an optional `scope?: TypeScope`. Pass a `LocalScope` of bindings +to override `R` (etc.) without rebuilding anything: + +```ts +import { LocalScope } from '@aeye/gin'; + +// fn map(fn: (value: V, index: num) => R): list +const mapFn = r.fn( + r.obj({ fn: { type: r.fn(r.obj({ value: { type: V } /*…*/ }), r.alias('R')) } }), + r.list(r.alias('R')), + undefined, + { R: r.any() }, +); + +const local = new LocalScope(r, { R: r.num() }); +mapFn.call(local).returns!.simplify(local).name === 'list'; // list +``` + +Path-step `generic` bindings (`[..., {args, generic: {R: numDef}}]`) +work the same way: `CallStep.callSiteScope(calledType)` builds the +`LocalScope` once per call and threads it into the type's resolution +methods. The fn type itself is never cloned. + ### `typ` — types-as-values Sometimes you want a program to receive a *type* as an argument — e.g. @@ -357,9 +392,9 @@ Sometimes you want a program to receive a *type* as an argument — e.g. const fetchFn = r.fn( r.obj({ url: { type: r.text() }, - output: { type: r.optional(r.typ(r.generic('R'))) }, + output: { type: r.optional(r.typ(r.alias('R'))) }, }), - r.generic('R'), + r.alias('R'), undefined, { R: r.text() }, ); diff --git a/packages/gin/src/__tests__/binding-rules.test.ts b/packages/gin/src/__tests__/binding-rules.test.ts index c99aebf..56b8bdb 100644 --- a/packages/gin/src/__tests__/binding-rules.test.ts +++ b/packages/gin/src/__tests__/binding-rules.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest'; import { createRegistry, Engine, RESERVED_NAMES, checkBindingName, Problems } from '../index'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; /** * Tests for the user-binding hygiene rules added in `analysis.ts` @@ -36,7 +36,7 @@ describe('RESERVED_NAMES set', () => { describe('checkBindingName helper', () => { test('reserved name → binding.reserved error', () => { const p = new Problems(); - const scope: TypeScope = new Map(); + const scope: Locals = new Map(); checkBindingName('args', scope, p); expect(p.list).toHaveLength(1); expect(p.list[0]!.code).toBe('binding.reserved'); @@ -45,7 +45,7 @@ describe('checkBindingName helper', () => { test('name in scope → binding.shadow error', () => { const p = new Problems(); - const scope: TypeScope = new Map(); + const scope: Locals = new Map(); scope.set('foo', e.registry.num()); checkBindingName('foo', scope, p); expect(p.list).toHaveLength(1); @@ -57,7 +57,7 @@ describe('checkBindingName helper', () => { // A name that is BOTH reserved AND in scope reports as reserved // (clearer message; the helper returns after the reserved branch). const p = new Problems(); - const scope: TypeScope = new Map(); + const scope: Locals = new Map(); scope.set('args', e.registry.any()); checkBindingName('args', scope, p); expect(p.list).toHaveLength(1); @@ -66,7 +66,7 @@ describe('checkBindingName helper', () => { test('fresh non-reserved name → no error', () => { const p = new Problems(); - const scope: TypeScope = new Map(); + const scope: Locals = new Map(); checkBindingName('myVar', scope, p); expect(p.list).toHaveLength(0); }); diff --git a/packages/gin/src/__tests__/call-type-aliases.test.ts b/packages/gin/src/__tests__/call-type-aliases.test.ts index bfdf273..989ea9a 100644 --- a/packages/gin/src/__tests__/call-type-aliases.test.ts +++ b/packages/gin/src/__tests__/call-type-aliases.test.ts @@ -1,22 +1,24 @@ import { describe, test, expect } from 'vitest'; import { createRegistry, Engine, FnType, ListType, type TypeDef } from '../index'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; /** - * Call-level type aliases (`CallDef.types`) — verify the inliner - * resolves bare `{name: ''}` references in args/returns/throws - * /get/set, that round-trip preserves the source form, that - * substitution drops aliases, and that the various validation cases - * throw with the expected error codes. + * Call-level type aliases (`CallDef.types`) — declared aliases are + * bound into a `LocalScope` while the call's slots parse, so bare + * `{name: ''}` references inside `args` / `returns` / `throws` + * resolve to AliasType wrappers around the alias's parsed Type. + * + * Round-trip is symmetric: `toJSON` re-emits the alias map and the + * bare-name references; `parse` rebuilds the same structure. */ const r = createRegistry(); const e = new Engine(r); -const numLit = (n: number) => ({ kind: 'new', type: { name: 'num' }, value: n }) as const; - describe('CallDef.types — basic resolution', () => { - test('alias referenced twice in args resolves identically to inlined form', () => { - const aliased = r.parse({ + test('alias referenced twice in args resolves to the alias target', () => { + const fn = r.parse({ name: 'function', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, @@ -24,21 +26,16 @@ describe('CallDef.types — basic resolution', () => { returns: { name: 'counter' }, }, }); - const inlined = r.parse({ - name: 'function', - call: { - args: { name: 'object', props: { - a: { type: { name: 'num', options: { whole: true, min: 1 } } }, - b: { type: { name: 'num', options: { whole: true, min: 1 } } }, - } }, - returns: { name: 'num', options: { whole: true, min: 1 } }, - }, - }); - expect(aliased.toCode()).toContain('a: num'); - expect(aliased.toCode()).toContain('b: num'); - // Structural equality on the inlined parsed forms. - expect((aliased as FnType)._call.args.toJSON()).toEqual((inlined as FnType)._call.args.toJSON()); - expect((aliased as FnType)._call.returns?.toJSON()).toEqual((inlined as FnType)._call.returns?.toJSON()); + // toCode resolves through AliasType.simplify-style behavior — both + // `a` and `b` show as the alias's resolved underlying type. + expect(fn.toCode()).toContain('a: counter'); + expect(fn.toCode()).toContain('b: counter'); + // The parsed args' value Type for `a` and `b` is an AliasType + // pointing to `counter`; resolved properties reflect the underlying + // num{whole, min:1}. + const fields = ((fn as FnType)._call.args as unknown as { fields: Record }).fields; + expect(fields.a!.type.valid(5)).toBe(true); + expect(fields.a!.type.valid(0)).toBe(false); }); test('sequential aliases — later refs earlier', () => { @@ -54,29 +51,37 @@ describe('CallDef.types — basic resolution', () => { }, }); const items = ((fn as FnType)._call.args as unknown as { fields: Record }) - .fields['items']!.type as ListType; - expect(items.name).toBe('list'); - expect(items.item.name).toBe('num'); - // The element type's options carry through. - expect((items.item.options as { min?: number }).min).toBe(1); + .fields['items']!.type as { simplify(): ListType }; + // items is an AliasType('B'); its resolved target is list. + const list = items.simplify() as ListType; + expect(list.name).toBe('list'); + // The list's V is itself an alias for A; A → num{min:1, whole:true}. + const v = (list.item as { simplify(): { name: string; options: { min?: number } } }).simplify(); + expect(v.name).toBe('num'); + expect(v.options.min).toBe(1); }); - test('alias references generic — bind substitutes inside the inlined tree', () => { + test('alias references generic — extra-scope T=text resolves through the alias', () => { const fn = r.parse({ name: 'function', - generic: { T: { name: 'generic', options: { name: 'T' } } }, + generic: { T: { name: 'T' } }, call: { types: { - valueList: { name: 'list', generic: { V: { name: 'generic', options: { name: 'T' } } } }, + valueList: { name: 'list', generic: { V: { name: 'T' } } }, }, args: { name: 'object', props: { items: { type: { name: 'valueList' } } } }, - returns: { name: 'generic', options: { name: 'T' } }, + returns: { name: 'T' }, }, }); - const bound = fn.bind({ T: r.text() }); - const items = ((bound as FnType)._call.args as unknown as { fields: Record }) - .fields['items']!.type as ListType; - expect(items.item.name).toBe('text'); + const local = new LocalScope(r, { T: r.text() }); + // items.type is AliasType('valueList'); its captured scope binds + // valueList → list. With the extra scope binding + // T → text, the resolved chain is list. + const items = ((fn as FnType)._call.args.props() as Record)['items']! + .type as AliasType; + const list = items.simplify(local) as ListType; + expect(list.name).toBe('list'); + expect((list.item as AliasType).simplify(local).name).toBe('text'); }); }); @@ -94,12 +99,12 @@ describe('CallDef.types — round-trip', () => { const json = fn.toJSON(); expect(json.call?.types).toBeDefined(); expect(json.call?.types?.['counter']).toEqual({ name: 'num', options: { whole: true, min: 1 } }); - // The args slot still references the alias by name (NOT inlined). + // The args slot still references the alias by NAME (bare form). expect(json.call?.args).toEqual({ name: 'object', props: { a: { type: { name: 'counter' } } } }); expect(json.call?.returns).toEqual({ name: 'counter' }); }); - test('parse → toJSON → parse produces structurally identical inlined args', () => { + test('parse → toJSON → parse produces structurally identical args', () => { const def: TypeDef = { name: 'function', call: { @@ -117,87 +122,26 @@ describe('CallDef.types — round-trip', () => { expect((a as FnType)._call.returns?.toJSON()).toEqual((b as FnType)._call.returns?.toJSON()); }); - test('post-bind toJSON drops `types` and emits inlined args', () => { + test('toJSON output is stable — call-site bindings do not mutate the source', () => { + // Without an eager bind step, the FnType instance is unchanged + // regardless of which scopes consult it. toJSON always emits the + // declared shape — `T` survives bare, `box` survives. const fn = r.parse({ name: 'function', - generic: { T: { name: 'generic', options: { name: 'T' } } }, + generic: { T: { name: 'T' } }, call: { - types: { box: { name: 'list', generic: { V: { name: 'generic', options: { name: 'T' } } } } }, + types: { box: { name: 'list', generic: { V: { name: 'T' } } } }, args: { name: 'object', props: { v: { type: { name: 'box' } } } }, - returns: { name: 'generic', options: { name: 'T' } }, + returns: { name: 'T' }, }, }); - const bound = fn.bind({ T: r.text() }); - const j = bound.toJSON(); - expect(j.call?.types).toBeUndefined(); - // args is now the fully-inlined-and-bound form (list). - const v = (j.call?.args as { props?: Record }).props!['v']!.type; - expect(v.name).toBe('list'); - }); -}); - -describe('CallDef.types — validation errors', () => { - test('alias name conflicts with built-in class → throws', () => { - expect(() => r.parse({ - name: 'function', - call: { - types: { list: { name: 'num' } }, - args: { name: 'object' }, - }, - })).toThrow(/call\.types\.name-conflict/); - }); - - test('empty alias name → throws', () => { - expect(() => r.parse({ - name: 'function', - call: { - types: { '': { name: 'num' } }, - args: { name: 'object' }, - }, - })).toThrow(/call\.types\.empty-name/); - }); - - test('forward reference → throws', () => { - expect(() => r.parse({ - name: 'function', - call: { - types: { - A: { name: 'B' }, // refs B before B is declared - B: { name: 'num' }, - }, - args: { name: 'object' }, - }, - })).toThrow(/call\.types\.forward-ref/); - }); - - test('self reference → throws', () => { - expect(() => r.parse({ - name: 'function', - call: { - types: { recur: { name: 'list', generic: { V: { name: 'recur' } } } }, - args: { name: 'object' }, - }, - })).toThrow(/call\.types\.forward-ref/); - }); - - test('alias name in `extends` → throws extends-alias', () => { - expect(() => r.parse({ - name: 'function', - call: { - types: { Foo: { name: 'num' } }, - args: { name: 'object', props: { x: { type: { name: 'obj', extends: 'Foo' } } } }, - }, - })).toThrow(/call\.types\.extends-alias/); - }); - - test('alias name in `satisfies` → throws extends-alias', () => { - expect(() => r.parse({ - name: 'function', - call: { - types: { Bar: { name: 'num' } }, - args: { name: 'object', props: { x: { type: { name: 'obj', satisfies: ['Bar'] } } } }, - }, - })).toThrow(/call\.types\.extends-alias/); + // Use the type with an extra scope (R=text) — this does NOT mutate + // anything; no rebuild happens. + const local = new LocalScope(r, { T: r.text() }); + fn.call(local); // exercise the call-site path + const j = fn.toJSON(); + expect(j.call?.types?.['box']).toEqual({ name: 'list', generic: { V: { name: 'T' } } }); + expect(j.call?.returns).toEqual({ name: 'T' }); }); }); @@ -222,93 +166,6 @@ describe('CallDef.types — ExprDef bodies', () => { }); }); -describe('CallDef.types — lambdas inherit aliases from their fnType', () => { - test('lambda body referencing a call.types alias parses (was failing before)', () => { - const lam = r.parseExpr({ - kind: 'lambda', - type: { - name: 'function', - call: { - types: { positiveInt: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object' }, - returns: { name: 'positiveInt' }, - }, - }, - body: { kind: 'new', type: { name: 'positiveInt' }, value: 5 }, - }); - expect(lam.kind).toBe('lambda'); - // The body's parsed form has the alias inlined (so the engine sees - // a real num type, not an unresolvable name). - const bodyJson = lam.body.toJSON() as { type: { name: string; options?: { min?: number } } }; - expect(bodyJson.type.name).toBe('num'); - expect(bodyJson.type.options?.min).toBe(1); - }); - - test('lambda constraint can also reference call.types aliases', () => { - const lam = r.parseExpr({ - kind: 'lambda', - type: { - name: 'function', - call: { - types: { positiveInt: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object', props: { n: { type: { name: 'positiveInt' } } } }, - returns: { name: 'bool' }, - }, - }, - // Constraint compares args.n against a positiveInt literal. - constraint: { - kind: 'get', - path: [ - { prop: 'args' }, { prop: 'n' }, { prop: 'gte' }, - { args: { other: { kind: 'new', type: { name: 'positiveInt' }, value: 1 } } }, - ], - }, - body: { kind: 'new', type: { name: 'bool' }, value: true }, - }); - expect(lam.constraint).toBeDefined(); - }); - - test('lambda toJSON round-trips with alias refs intact in body', () => { - const def = { - kind: 'lambda' as const, - type: { - name: 'function', - call: { - types: { positiveInt: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object' }, - returns: { name: 'positiveInt' }, - }, - }, - body: { kind: 'new' as const, type: { name: 'positiveInt' }, value: 5 }, - }; - const lam = r.parseExpr(def); - const json = lam.toJSON() as { body: { type: { name: string } } }; - // Source body preserved — emits `{name: 'positiveInt'}`, NOT the - // inlined `{name: 'num', options: {...}}`. - expect(json.body.type.name).toBe('positiveInt'); - // Re-parse should still work and produce the same result. - const lam2 = r.parseExpr(json as never); - const body2 = lam2.body.toJSON() as { type: { name: string } }; - expect(body2.type.name).toBe('num'); // re-inlined on parse - }); - - test('lambda WITHOUT call.types behaves exactly as before', () => { - const lam = r.parseExpr({ - kind: 'lambda', - type: { - name: 'function', - call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, - returns: { name: 'num' }, - }, - }, - body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'n' }] }, - }); - const json = lam.toJSON() as { body: { kind: string } }; - expect(json.body.kind).toBe('get'); - }); -}); - describe('CallDef.types — toCodeDefinition rendering', () => { test('aliases render as `type X = …;` lines before the call signature', () => { const fn = r.parse({ @@ -320,16 +177,10 @@ describe('CallDef.types — toCodeDefinition rendering', () => { }, }); const def = fn.toCodeDefinition(); - // The alias line precedes the call-signature line. const aliasIdx = def.indexOf('type counter'); const callIdx = def.indexOf('(n:'); expect(aliasIdx).toBeGreaterThanOrEqual(0); expect(callIdx).toBeGreaterThanOrEqual(0); expect(aliasIdx).toBeLessThan(callIdx); - // The call sig itself uses the alias-resolved name (num), since - // formatParams renders parsed Types — whether it shows `counter` - // or `num` depends on the inlined tree. We just confirm the - // signature is present and the alias header is too. - expect(def).toContain('type counter'); }); }); diff --git a/packages/gin/src/__tests__/deep-set.test.ts b/packages/gin/src/__tests__/deep-set.test.ts index f867542..1d078d5 100644 --- a/packages/gin/src/__tests__/deep-set.test.ts +++ b/packages/gin/src/__tests__/deep-set.test.ts @@ -196,7 +196,7 @@ describe('deep set: method call → prop set', () => { }, }, self: { - type: r.fn(r.obj({}), r.ref('tattler')), + type: r.fn(r.obj({}), r.alias('tattler')), get: { kind: 'get', path: [{ prop: 'this' }] }, }, }, diff --git a/packages/gin/src/__tests__/extensibility.test.ts b/packages/gin/src/__tests__/extensibility.test.ts index 4eafdbd..cd0a087 100644 --- a/packages/gin/src/__tests__/extensibility.test.ts +++ b/packages/gin/src/__tests__/extensibility.test.ts @@ -110,7 +110,7 @@ describe('extensibility: Type.toCode is fully polymorphic', () => { r.enum({ A: 'a' }, r.text()), r.literal(r.num(), 7), r.fn(r.obj({}), r.num()), r.iface({ props: { x: { type: { name: 'num' } } } }), - r.ref('Foo'), r.generic('T'), + r.alias('Foo'), r.alias('T'), r.date(), r.timestamp(), r.duration(), r.color(), r.extend('num', { name: 'Positive', options: { min: 0 } }), ]; diff --git a/packages/gin/src/__tests__/extension-generics.test.ts b/packages/gin/src/__tests__/extension-generics.test.ts index 1421cbe..2c81377 100644 --- a/packages/gin/src/__tests__/extension-generics.test.ts +++ b/packages/gin/src/__tests__/extension-generics.test.ts @@ -1,22 +1,21 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../index'; import { Extension } from '../extension'; -import { GenericType } from '../types/generic'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; /** * Extensions can declare their own generic parameters. The parameters - * live on `local.generic` (decl + current binding map), are substituted - * via `.bind({T: ...})`, and propagate through local.props / get / call. - * - * Convention: use `registry.generic('T')` as both the declaration value - * AND the placeholder inside props/etc. That way `.bind(...)` updates - * the declared binding AND the usage sites in one substitute walk. + * live on `local.generic` (decl + current binding map) and are + * referenced as `r.alias('T')` inside `props`/`get`/`call`. There is + * no eager `bind` machinery — call sites pass an extra `TypeScope` + * binding T to a concrete type, and AliasType resolution sees it. */ describe('Extension generics', () => { const r = createRegistry(); - test('declare + bind: Box', () => { - const T = r.generic('T'); + test('declare: Box placeholders survive as AliasType', () => { + const T = r.alias('T'); const Box = r.extend('object', { name: 'Box', generic: { T }, @@ -26,22 +25,32 @@ describe('Extension generics', () => { }); r.register(Box); - // Template: T unbound - expect(Box.generic.T).toBeInstanceOf(GenericType); - expect((Box as Extension).local.props!.value!.type).toBeInstanceOf(GenericType); + expect(Box.generic.T).toBeInstanceOf(AliasType); + expect((Box as Extension).local.props!.value!.type).toBeInstanceOf(AliasType); + }); + + test('Box resolution: extra-scope T=num makes value.type behave as num', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Box = reg.extend('object', { + name: 'Box', + generic: { T }, + props: { value: { type: T } }, + }); + reg.register(Box); - // Bind T = num - const NumBox = Box.bind({ T: r.num() }); - expect(NumBox).toBeInstanceOf(Extension); - expect(NumBox.generic.T!.name).toBe('num'); - // The prop's type is now num, not a placeholder. - expect((NumBox as Extension).local.props!.value!.type.name).toBe('num'); + const local = new LocalScope(reg, { T: reg.num() }); + const valueProp = (Box as Extension).local.props!.value!; + expect((valueProp.type as AliasType).simplify(local).name).toBe('num'); + expect(valueProp.type.valid(5, local)).toBe(true); + expect(valueProp.type.valid('x', local)).toBe(false); }); - test('multi-param: Pair', () => { - const A = r.generic('A'); - const B = r.generic('B'); - const Pair = r.extend('object', { + test('multi-param: Pair via extra-scope', () => { + const reg = createRegistry(); + const A = reg.alias('A'); + const B = reg.alias('B'); + const Pair = reg.extend('object', { name: 'Pair', generic: { A, B }, props: { @@ -49,34 +58,39 @@ describe('Extension generics', () => { second: { type: B }, }, }); - r.register(Pair); + reg.register(Pair); - const NumText = Pair.bind({ A: r.num(), B: r.text() }); - expect((NumText as Extension).local.props!.first!.type.name).toBe('num'); - expect((NumText as Extension).local.props!.second!.type.name).toBe('text'); + const local = new LocalScope(reg, { A: reg.num(), B: reg.text() }); + expect((Pair as Extension).local.props!.first!.type.valid(5, local)).toBe(true); + expect((Pair as Extension).local.props!.first!.type.valid('x', local)).toBe(false); + expect((Pair as Extension).local.props!.second!.type.valid('x', local)).toBe(true); + expect((Pair as Extension).local.props!.second!.type.valid(5, local)).toBe(false); }); - test('generic on call: identity(x: T): T', () => { - const T = r.generic('T'); - const Fn = r.extend('function', { + test('generic on call: identity(x: T): T resolves via extra-scope', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Fn = reg.extend('function', { name: 'identity', generic: { T }, - call: { args: r.obj({ x: { type: T } }), returns: T }, + call: { args: reg.obj({ x: { type: T } }), returns: T }, }); - r.register(Fn); - - const bound = Fn.bind({ T: r.num() }); - const call = bound.call()!; - expect(call.returns!.name).toBe('num'); - // args is an obj; its `x` field is num. - const argsObj = call.args; - expect(argsObj.prop('x')!.type.name).toBe('num'); + reg.register(Fn); + + const local = new LocalScope(reg, { T: reg.num() }); + const call = Fn.call(local)!; + // `returns` is AliasType('T'); .simplify(local) → num. + expect(call.returns?.simplify(local).name).toBe('num'); + // args is an obj; its `x` field, accessed with local scope, + // resolves through to num. + expect(call.args.prop('x', local)!.type.valid(5, local)).toBe(true); }); - test('generic on get: Bag[K]: V', () => { - const K = r.generic('K'); - const V = r.generic('V'); - const Bag = r.extend('object', { + test('generic on get: Bag[K]: V resolves via extra-scope', () => { + const reg = createRegistry(); + const K = reg.alias('K'); + const V = reg.alias('V'); + const Bag = reg.extend('object', { name: 'Bag', generic: { K, V }, get: { @@ -84,48 +98,53 @@ describe('Extension generics', () => { value: V, }, }); - r.register(Bag); + reg.register(Bag); - const bound = Bag.bind({ K: r.text(), V: r.num() }); - const gs = bound.get()!; - expect(gs.key.name).toBe('text'); - expect(gs.value.name).toBe('num'); + const local = new LocalScope(reg, { K: reg.text(), V: reg.num() }); + const gs = Bag.get(local)!; + // gs.key / gs.value are AliasTypes; resolved via local. + expect((gs.key as AliasType).simplify(local).name).toBe('text'); + expect((gs.value as AliasType).simplify(local).name).toBe('num'); }); - test('JSON round-trip preserves generic', () => { - const T = r.generic('T'); - const Holder = r.extend('object', { + test('JSON round-trip preserves generic placeholder', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Holder = reg.extend('object', { name: 'Holder', generic: { T }, props: { item: { type: T } }, }); - r.register(Holder); + reg.register(Holder); - const NumHolder = Holder.bind({ T: r.num() }); - const json = NumHolder.toJSON(); - expect(json.generic?.T).toEqual({ name: 'num', options: undefined }); - expect(json.props?.item?.type).toEqual({ name: 'num', options: undefined }); + const json = Holder.toJSON(); + // `T` survives in the JSON as a bare-name AliasType ref. + expect(json.generic?.T).toEqual({ name: 'T' }); + expect(json.props?.item?.type).toEqual({ name: 'T' }); - const reparsed = r.parse(json) as Extension; - expect(reparsed.local.props!.item!.type.name).toBe('num'); + const reparsed = reg.parse(json) as Extension; + expect(reparsed.local.props!.item!.type).toBeInstanceOf(AliasType); + const local = new LocalScope(reg, { T: reg.num() }); + expect((reparsed.local.props!.item!.type as AliasType).simplify(local).name).toBe('num'); }); - test('bind substitutes inside props, accessible via props()', () => { - const T = r.generic('T'); - const Wrapper = r.extend('object', { + test('extra-scope inside props is visible via props()', () => { + const reg = createRegistry(); + const T = reg.alias('T'); + const Wrapper = reg.extend('object', { name: 'Wrapper', generic: { T }, props: { inside: { type: T } }, }); - r.register(Wrapper); + reg.register(Wrapper); - const StrWrap = Wrapper.bind({ T: r.text({ minLength: 1 }) }); - // Access via the merged props() surface — T is replaced by the text type - // with its options preserved. - const inside = StrWrap.prop('inside'); + const local = new LocalScope(reg, { T: reg.text({ minLength: 1 }) }); + const inside = Wrapper.prop('inside', local); expect(inside).toBeDefined(); - expect(inside!.type.name).toBe('text'); - const textOpts = (inside!.type.options as { minLength?: number }); - expect(textOpts.minLength).toBe(1); + // The captured Prop's type is AliasType('T'); resolution via local + // returns the bound text type with options preserved. + const resolved = (inside!.type as AliasType).simplify(local); + expect(resolved.name).toBe('text'); + expect((resolved.options as { minLength?: number }).minLength).toBe(1); }); }); diff --git a/packages/gin/src/__tests__/gaps-generic.test.ts b/packages/gin/src/__tests__/gaps-generic.test.ts index a72f9a4..81f5fb9 100644 --- a/packages/gin/src/__tests__/gaps-generic.test.ts +++ b/packages/gin/src/__tests__/gaps-generic.test.ts @@ -10,8 +10,8 @@ describe('PathCall.generic — explicit generic bindings', () => { // Build identity as a standalone fn typed against T. const identity = r.fn( - r.obj({ x: { type: r.generic('T') } }), - r.generic('T'), + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), ); // Run typeOf on a call with explicit generic binding; the returns diff --git a/packages/gin/src/__tests__/generic-methods.test.ts b/packages/gin/src/__tests__/generic-methods.test.ts index 67df667..514b7e1 100644 --- a/packages/gin/src/__tests__/generic-methods.test.ts +++ b/packages/gin/src/__tests__/generic-methods.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; import { FnType } from '../types/fn'; -import { GenericType } from '../types/generic'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; /** * Method-level generics: `r.method(args, returns, id, { generic: {...} })` @@ -13,7 +14,7 @@ import { GenericType } from '../types/generic'; describe('method-level generics on fn/method', () => { test('r.fn accepts a generic map and stores it on FnType.generic', () => { const r = createRegistry(); - const f = r.fn(r.obj({}), r.generic('T'), undefined, { T: r.any() }) as FnType; + const f = r.fn(r.obj({}), r.alias('T'), undefined, { T: r.any() }) as FnType; expect(f).toBeInstanceOf(FnType); expect(Object.keys(f.generic)).toEqual(['T']); expect(f.generic.T!.name).toBe('any'); @@ -22,8 +23,8 @@ describe('method-level generics on fn/method', () => { test('r.method forwards options.generic into the fn type', () => { const r = createRegistry(); const prop = r.method( - { other: r.generic('T') }, - r.generic('T'), + { other: r.alias('T') }, + r.alias('T'), 'example.op', { generic: { T: r.any() } }, ); @@ -42,8 +43,8 @@ describe('method-level generics on fn/method', () => { test('toCode renders method generics as prefix on fn signatures', () => { const r = createRegistry(); const f = r.fn( - r.obj({ x: { type: r.generic('T') } }), - r.generic('T'), + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), undefined, { T: r.any() }, ); @@ -53,7 +54,7 @@ describe('method-level generics on fn/method', () => { test('toCode with bound generic renders ', () => { const r = createRegistry(); // Constraint: T extends num. - const f = r.fn(r.obj({ x: { type: r.generic('T') } }), r.generic('T'), undefined, { + const f = r.fn(r.obj({ x: { type: r.alias('T') } }), r.alias('T'), undefined, { T: r.num(), }); expect(f.toCode()).toBe('(x: T): T'); @@ -62,8 +63,8 @@ describe('method-level generics on fn/method', () => { test('toCode with multiple generics — mix of bound and unbound', () => { const r = createRegistry(); const f = r.fn( - r.obj({ a: { type: r.generic('A') }, b: { type: r.generic('B') } }), - r.generic('A'), + r.obj({ a: { type: r.alias('A') }, b: { type: r.alias('B') } }), + r.alias('A'), undefined, { A: r.any(), B: r.num() }, ); @@ -81,8 +82,8 @@ describe('FnType.generic — JSON round-trip', () => { test('toJSON serializes generic map; parse reconstructs it', () => { const r = createRegistry(); const f = r.fn( - r.obj({ x: { type: r.generic('T') } }), - r.generic('T'), + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), undefined, { T: r.num() }, ); @@ -104,19 +105,19 @@ describe('FnType.generic — JSON round-trip', () => { test('round-trip preserves generic placeholders inside args/returns', () => { const r = createRegistry(); const f = r.fn( - r.obj({ fn: { type: r.fn(r.obj({ v: { type: r.generic('T') } }), r.bool()) } }), - r.generic('T'), + r.obj({ fn: { type: r.fn(r.obj({ v: { type: r.alias('T') } }), r.bool()) } }), + r.alias('T'), undefined, { T: r.any() }, ); const back = r.parse(f.toJSON()) as FnType; expect(Object.keys(back.generic)).toEqual(['T']); - // Inner fn arg type should still be a GenericType named 'T'. + // Inner fn arg type should still be a AliasType named 'T'. const innerFnType = (back.call().args as unknown as { fields?: Record }) .fields?.fn?.type as FnType; const innerArgs = innerFnType.call().args as unknown as { fields?: Record }; - const vType = innerArgs.fields?.v?.type as GenericType; - expect(vType).toBeInstanceOf(GenericType); + const vType = innerArgs.fields?.v?.type as AliasType; + expect(vType).toBeInstanceOf(AliasType); expect(vType.options.name).toBe('T'); }); }); @@ -124,7 +125,7 @@ describe('FnType.generic — JSON round-trip', () => { describe('toCodeDefinition — method-level generics', () => { test('list.map shows (...): list — R is method-only, not inherited', () => { const r = createRegistry(); - const listT = r.list(r.generic('V')); + const listT = r.list(r.alias('V')); const def = listT.toCodeDefinition(); expect(def).toContain('type list'); expect(def).toContain('map(fn: (value: V, index: num): R): list'); @@ -132,7 +133,7 @@ describe('toCodeDefinition — method-level generics', () => { test('filter inherits V but introduces no new generic → no <> suffix', () => { const r = createRegistry(); - const listT = r.list(r.generic('V')); + const listT = r.list(r.alias('V')); const def = listT.toCodeDefinition(); // filter uses only V (from the outer type), not R — so no method-level <>. expect(def).toMatch(/filter\(fn: \(value: V, index: num\): bool\): list/); @@ -144,8 +145,8 @@ describe('toCodeDefinition — method-level generics', () => { // Type declares V; method redundantly declares V as well — the method // generic list should NOT include V (since it's inherited from outer). const fn = r.fn( - r.obj({ x: { type: r.generic('V') } }), - r.generic('V'), + r.obj({ x: { type: r.alias('V') } }), + r.alias('V'), undefined, { V: r.any() }, ); @@ -161,34 +162,43 @@ describe('toCodeDefinition — method-level generics', () => { }); }); -describe('runtime behavior — CallStep.bindGeneric', () => { - test('fn type.bind substitutes method generics through nested positions', () => { +describe('runtime behavior — call-site generic resolution via TypeScope', () => { + test('fn signature with extra-scope R=num resolves R through nested positions', () => { const r = createRegistry(); const f = r.fn( - r.obj({ v: { type: r.generic('R') } }), - r.list(r.generic('R')), + r.obj({ v: { type: r.alias('R') } }), + r.list(r.alias('R')), undefined, { R: r.any() }, ); - const bound = f.bind({ R: r.num() }) as FnType; - // After binding, R should be substituted everywhere. - const args = bound.call().args as unknown as { fields?: Record }; - expect(args.fields?.v?.type.name).toBe('num'); - expect(bound.call().returns?.name).toBe('list'); - }); - - test('bind with missing key leaves unbound placeholders intact', () => { + const local = new LocalScope(r, { R: r.num() }); + // The fn type itself is unchanged — call() returns the same Call. + // But when accessed with `local`, AliasTypes inside resolve. + const args = f.call(local).args as unknown as { fields?: Record }; + const vType = args.fields!.v!.type; + expect(vType.simplify(local).name).toBe('num'); + // Returns: list; the list itself stays a ListType, but its + // item is an AliasType resolving via local to num. + const ret = f.call(local).returns!; + expect(ret.name).toBe('list'); + const item = (ret as unknown as { item: AliasType }).item; + expect(item.simplify(local).name).toBe('num'); + }); + + test('extra-scope without matching name leaves placeholders unresolved', () => { const r = createRegistry(); const f = r.fn( - r.obj({ v: { type: r.generic('R') } }), - r.generic('R'), + r.obj({ v: { type: r.alias('R') } }), + r.alias('R'), undefined, { R: r.any() }, ); - const bound = f.bind({ NOT_R: r.num() }) as FnType; - const args = bound.call().args as unknown as { fields?: Record }; - expect(args.fields?.v?.type).toBeInstanceOf(GenericType); - expect(args.fields?.v?.type.options.name).toBe('R'); + const local = new LocalScope(r, { NOT_R: r.num() }); + const args = f.call(local).args as unknown as { fields?: Record }; + expect(args.fields!.v!.type).toBeInstanceOf(AliasType); + expect(args.fields!.v!.type.options.name).toBe('R'); + // simplify(local) returns self (R isn't bound in local). + expect(args.fields!.v!.type.simplify(local)).toBe(args.fields!.v!.type); }); }); @@ -197,7 +207,7 @@ describe('invalid / edge cases', () => { const r = createRegistry(); // A method's generic R is a type-level placeholder — at runtime, before // binding, it's indistinguishable from `any`: everything is valid. - const placeholder = r.generic('R'); + const placeholder = r.alias('R'); expect(placeholder.valid(5)).toBe(true); expect(placeholder.valid('hi')).toBe(true); expect(placeholder.valid(null)).toBe(true); @@ -206,7 +216,7 @@ describe('invalid / edge cases', () => { test('method generic bound to a constraint still renders correctly', () => { const r = createRegistry(); const prop = r.method( - { key: r.generic('K') }, + { key: r.alias('K') }, r.bool(), 'example.has', { generic: { K: r.text() } }, @@ -219,7 +229,7 @@ describe('invalid / edge cases', () => { // Build a bare FnType whose generic list happens to contain V — when // placed inside a list's definition, V would be filtered out. // Here we just verify the fn itself (standalone) shows . - const f = r.fn(r.obj({ x: { type: r.generic('V') } }), r.generic('V'), undefined, { + const f = r.fn(r.obj({ x: { type: r.alias('V') } }), r.alias('V'), undefined, { V: r.any(), }); expect(f.toCode()).toContain(''); diff --git a/packages/gin/src/__tests__/generic.test.ts b/packages/gin/src/__tests__/generic.test.ts index a6ba1ae..4568f4f 100644 --- a/packages/gin/src/__tests__/generic.test.ts +++ b/packages/gin/src/__tests__/generic.test.ts @@ -1,54 +1,75 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; -import { GenericType } from '../types/generic'; -import { NumType } from '../types/num'; +import { AliasType } from '../types/alias'; +import { LocalScope } from '../type-scope'; -describe('GenericType', () => { +/** + * Generic-parameter behavior under the unified AliasType + scope-based + * resolution. A bare `r.alias('V')` resolves through its captured + * scope; an extra TypeScope can be passed at access time to override + * the captured layer (this is how call-site `` bindings reach + * AliasTypes inside a fn signature without rebuilding the type tree). + * No `Type.bind` / `substitute` API any more. + */ +describe('AliasType (generic flavor)', () => { const r = createRegistry(); test('builder stores the param name', () => { - const g = r.generic('V') as GenericType; - expect(g).toBeInstanceOf(GenericType); + const g = r.alias('V') as AliasType; + expect(g).toBeInstanceOf(AliasType); expect(g.options.name).toBe('V'); }); test('valid accepts anything before binding', () => { - const g = r.generic('V'); + const g = r.alias('V'); expect(g.valid(5)).toBe(true); expect(g.valid('x')).toBe(true); }); test('compatible is true before binding', () => { - expect(r.generic('V').compatible(r.num())).toBe(true); + expect(r.alias('V').compatible(r.num())).toBe(true); }); test('flexible is true', () => { - expect(r.generic('V').flexible()).toBe(true); + expect(r.alias('V').flexible()).toBe(true); }); - test('bind resolves against matching name', () => { - const g = r.generic('V'); - const bound = g.bind({ V: r.num() }); - expect(bound).toBeInstanceOf(NumType); + test('extra-scope resolution: V → num via passed scope', () => { + // The captured scope (registry root) doesn't know V. But when we + // pass an extra LocalScope binding V to num, the AliasType's + // value-side ops delegate to num. + const g = r.alias('V'); + const local = new LocalScope(r, { V: r.num() }); + expect(g.valid(5, local)).toBe(true); + expect(g.valid('x', local)).toBe(false); // num rejects strings + expect(g.simplify(local).name).toBe('num'); // collapses to the bound type }); - test('bind keeps self when no matching binding', () => { - const g = r.generic('V'); - const bound = g.bind({ X: r.num() }); - expect(bound).toBeInstanceOf(GenericType); + test('extra-scope without matching name is a no-op', () => { + const g = r.alias('V'); + const local = new LocalScope(r, { X: r.num() }); + expect(g.simplify(local)).toBe(g); // unresolved → self }); - test('bind substitutes through a list type', () => { - const listGeneric = r.list(r.generic('V')); - const bound = listGeneric.bind({ V: r.num() }); - // after binding, the list's item should be num - expect((bound as any).item?.name).toBe('num'); + test('list resolves V via extra scope on parse', () => { + // The list type contains AliasType('V') captured at registry root. + // Parsing a list of 5s should validate when V is bound to num. + const list = r.list(r.alias('V')); + const local = new LocalScope(r, { V: r.num() }); + const v = list.parse([1, 2, 3], local); + expect(v.raw.length).toBe(3); + expect(v.raw[0]!.type.name).toBe('alias'); // alias preserved + expect((v.raw[0]!.type as AliasType).simplify(local).name).toBe('num'); }); test('encode + parse roundtrip', () => { - const t = r.generic('T'); - const back = r.parse(t.toJSON()) as GenericType; - expect(back).toBeInstanceOf(GenericType); + const t = r.alias('T'); + const json = t.toJSON(); + expect(json).toEqual({ name: 'T' }); + // Bare-name 'T' is unknown to the root registry — re-parsed in + // root scope it stays an AliasType (forward-ref / placeholder). + const back = r.parse(json) as AliasType; + expect(back).toBeInstanceOf(AliasType); expect(back.options.name).toBe('T'); }); }); diff --git a/packages/gin/src/__tests__/ginny-docs.test.ts b/packages/gin/src/__tests__/ginny-docs.test.ts index 9b24abd..ddf1543 100644 --- a/packages/gin/src/__tests__/ginny-docs.test.ts +++ b/packages/gin/src/__tests__/ginny-docs.test.ts @@ -15,7 +15,9 @@ function placeholderize(r: ReturnType, cls: { NAME: strin const keys = Object.keys(canonical.generic); if (keys.length === 0) return canonical; const genericDef: Record = {}; - for (const k of keys) genericDef[k] = { name: 'generic', options: { name: k } }; + // Bare-name shape: `{name: 'V'}` parses to an AliasType('V') in + // the registry-root scope (unresolved → universal placeholder). + for (const k of keys) genericDef[k] = { name: k }; try { return cls.from({ name: cls.NAME, generic: genericDef } as TypeDef, r); } catch { return canonical; } } diff --git a/packages/gin/src/__tests__/recursive-types.test.ts b/packages/gin/src/__tests__/recursive-types.test.ts index dccb36a..a422f82 100644 --- a/packages/gin/src/__tests__/recursive-types.test.ts +++ b/packages/gin/src/__tests__/recursive-types.test.ts @@ -4,7 +4,7 @@ import { createRegistry } from '../registry'; /** * Self-referential types (tree `Node` with `children: Node[]`) and mutual * cycles (`Task.creator: User` + `User.tasks: list`) are expressible - * via `r.ref(name)` — RefType resolves lazily through the registry, so + * via `r.alias(name)` — RefType resolves lazily through the registry, so * cross-references don't need the target to exist at construction time. * * These tests pin the behavior end-to-end: construction, rendering, @@ -18,7 +18,7 @@ describe('recursive types', () => { name: 'Node', props: { value: { type: r.num() }, - children: { type: r.optional(r.list(r.ref('Node'))) }, + children: { type: r.optional(r.list(r.alias('Node'))) }, }, }); r.register(Node); @@ -37,7 +37,7 @@ describe('recursive types', () => { name: 'Node', props: { value: { type: r.num() }, - children: { type: r.optional(r.list(r.ref('Node'))) }, + children: { type: r.optional(r.list(r.alias('Node'))) }, }, }); r.register(Node); @@ -62,17 +62,19 @@ describe('recursive types', () => { name: 'Node', props: { value: { type: r.num() }, - children: { type: r.optional(r.list(r.ref('Node'))) }, + children: { type: r.optional(r.list(r.alias('Node'))) }, }, }); r.register(Node); const json = JSON.stringify(Node.toJSON()); - // Exactly one ref mention inside (for the children's inner type); - // no recursive explosion of the props tree. - const refMatches = json.match(/"name":"ref"/g) ?? []; - expect(refMatches.length).toBe(1); - expect(json).toContain('"options":{"name":"Node"}'); + // The self-reference uses the bare-name shape `{"name":"Node"}` — + // emitted exactly twice in the props tree: once as the outer + // type's `name` (the Node type itself) and once as the inner ref + // inside `children`'s list. No recursive explosion (would be 100s + // if Node's props were re-inlined inside its own children). + const matches = json.match(/"name":"Node"/g) ?? []; + expect(matches.length).toBe(2); }); test('mutual cycle: Task ↔ User', () => { @@ -81,7 +83,7 @@ describe('recursive types', () => { name: 'Task', props: { title: { type: r.text({ minLength: 1 }) }, - creator: { type: r.ref('User') }, + creator: { type: r.alias('User') }, }, }); r.register(Task); @@ -90,7 +92,7 @@ describe('recursive types', () => { name: 'User', props: { name: { type: r.text() }, - tasks: { type: r.list(r.ref('Task')) }, + tasks: { type: r.list(r.alias('Task')) }, }, }); r.register(User); @@ -110,7 +112,7 @@ describe('recursive types', () => { name: 'Task', props: { title: { type: r.text() }, - creator: { type: r.ref('User') }, + creator: { type: r.alias('User') }, }, }); r.register(Task); @@ -118,7 +120,7 @@ describe('recursive types', () => { name: 'User', props: { name: { type: r.text() }, - tasks: { type: r.list(r.ref('Task')) }, + tasks: { type: r.list(r.alias('Task')) }, }, }); r.register(User); @@ -136,13 +138,17 @@ describe('recursive types', () => { expect(User2.toCodeDefinition()).toContain('tasks: list'); }); - test('ref to an unregistered name resolves lazily — error surfaces on use', () => { + test('ref to an unregistered name resolves lazily — placeholder semantics', () => { const r = createRegistry(); - const ref = r.ref('DoesNotExist'); + const ref = r.alias('DoesNotExist'); // Construction + toJSON don't touch resolve(). expect(ref.toCode()).toBe('DoesNotExist'); - expect(ref.toJSON().name).toBe('ref'); - // But actually exercising the ref fails. - expect(() => ref.parse({})).toThrow(/not registered/); + // Bare-name JSON form — no `ref` wrapper. + expect(ref.toJSON().name).toBe('DoesNotExist'); + // Unresolved alias acts as a permissive placeholder (matches the + // unbound-generic behavior). Once `DoesNotExist` is registered, + // future calls would delegate to that target. + expect(ref.valid({})).toBe(true); + expect(ref.compatible(r.num())).toBe(true); }); }); diff --git a/packages/gin/src/__tests__/ref.test.ts b/packages/gin/src/__tests__/ref.test.ts index 0bd9560..ee384d5 100644 --- a/packages/gin/src/__tests__/ref.test.ts +++ b/packages/gin/src/__tests__/ref.test.ts @@ -1,19 +1,24 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; -import { RefType } from '../types/ref'; +import { AliasType } from '../types/alias'; import { NumType } from '../types/num'; -describe('RefType', () => { +/** + * Reference-style aliases: `r.alias(name)` produces a lazy bare-name + * reference. Resolution walks `scope.lookup`, hitting the registered + * named type or built-in class. Replaces the former dedicated `RefType`. + */ +describe('AliasType (reference flavor)', () => { const r = createRegistry(); test('builder stores the name', () => { - const t = r.ref('num') as RefType; - expect(t).toBeInstanceOf(RefType); + const t = r.alias('num') as AliasType; + expect(t).toBeInstanceOf(AliasType); expect(t.options.name).toBe('num'); }); test('resolves via registry for built-in', () => { - const t = r.ref('num'); + const t = r.alias('num'); expect(t.valid(5)).toBe(true); expect(t.valid('x')).toBe(false); }); @@ -22,33 +27,42 @@ describe('RefType', () => { const reg = createRegistry(); const custom = reg.extend('num', { name: 'myNum', options: { min: 0 } }); reg.register(custom); - const ref = reg.ref('myNum'); + const ref = reg.alias('myNum'); expect(ref.valid(5)).toBe(true); expect(ref.valid(-1)).toBe(false); }); - test('unresolved ref throws on use', () => { - expect(() => r.ref('does-not-exist').valid(1)).toThrow(); + test('unresolved alias is permissive (placeholder semantics)', () => { + // Forward-ref / unresolved name acts as an unbound placeholder: + // permissive valid/compatible, no props. Once the name registers, + // the alias starts delegating. + const t = r.alias('does-not-exist'); + expect(t.valid(1)).toBe(true); }); test('flexible is true', () => { - expect(r.ref('num').flexible()).toBe(true); + expect(r.alias('num').flexible()).toBe(true); }); test('props delegate to resolved target', () => { - const t = r.ref('num'); + const t = r.alias('num'); const p = t.props(); expect(p.add).toBeDefined(); }); test('simplify returns the resolved target', () => { - expect(r.ref('num').simplify()).toBeInstanceOf(NumType); + expect(r.alias('num').simplify()).toBeInstanceOf(NumType); }); test('encode + parse roundtrip', () => { - const t = r.ref('num'); - const back = r.parse(t.toJSON()) as RefType; - expect(back).toBeInstanceOf(RefType); - expect(back.options.name).toBe('num'); + const t = r.alias('num'); + const json = t.toJSON(); + expect(json).toEqual({ name: 'num' }); + // Re-parsing the bare-name form returns the canonical class + // instance directly (since 'num' is a built-in class), not an + // AliasType wrapper. Structural equality is preserved. + const back = r.parse(json); + expect(back.name).toBe('num'); + expect(back.valid(5)).toBe(true); }); }); diff --git a/packages/gin/src/__tests__/registry.test.ts b/packages/gin/src/__tests__/registry.test.ts index 8ceb6c0..69ec1d1 100644 --- a/packages/gin/src/__tests__/registry.test.ts +++ b/packages/gin/src/__tests__/registry.test.ts @@ -24,9 +24,15 @@ describe('Registry', () => { expect(t).toBeInstanceOf(Extension); }); - test('parse throws for unknown name', () => { + test('parse of bare unknown name returns a lazy alias placeholder', () => { + // Unknown bare names route through AliasType so forward refs and + // self-referential types parse without an existence check. The + // alias resolves via scope.lookup at use time; unresolved aliases + // behave permissively (compatible / valid both pass) until the + // target gets registered. const r = createRegistry(); - expect(() => r.parse({ name: 'unknown-type' })).toThrow(); + const t = r.parse({ name: 'unknown-type' }); + expect(t.name).toBe('alias'); }); test('parse throws for extends of unknown base', () => { @@ -78,8 +84,9 @@ describe('Registry', () => { expect(t.name).toBe('list'); }); - test('empty Registry (no builtins) rejects parse', () => { + test('empty Registry (no builtins) parses bare name as a lazy alias', () => { const r = new Registry(); - expect(() => r.parse({ name: 'num' })).toThrow(); + const t = r.parse({ name: 'num' }); + expect(t.name).toBe('alias'); }); }); diff --git a/packages/gin/src/__tests__/toCode.test.ts b/packages/gin/src/__tests__/toCode.test.ts index 39f4d0f..a19b67c 100644 --- a/packages/gin/src/__tests__/toCode.test.ts +++ b/packages/gin/src/__tests__/toCode.test.ts @@ -105,14 +105,14 @@ describe('Type.toCode — functions and references', () => { expect(fn.toCode()).toBe('(): void'); }); test('fn with generics', () => { - const fn = r.fn(r.obj({ x: { type: r.generic('T') } }), r.generic('T'), undefined, { T: r.any() }); + const fn = r.fn(r.obj({ x: { type: r.alias('T') } }), r.alias('T'), undefined, { T: r.any() }); expect(fn.toCode()).toBe('(x: T): T'); }); test('ref → bare name', () => { - expect(r.ref('User').toCode()).toBe('User'); + expect(r.alias('User').toCode()).toBe('User'); }); test('generic → bare name', () => { - expect(r.generic('T').toCode()).toBe('T'); + expect(r.alias('T').toCode()).toBe('T'); }); test('iface renders struct-style', () => { const t = r.iface({ diff --git a/packages/gin/src/__tests__/typ-type.test.ts b/packages/gin/src/__tests__/typ-type.test.ts index e48ef4d..71ad90c 100644 --- a/packages/gin/src/__tests__/typ-type.test.ts +++ b/packages/gin/src/__tests__/typ-type.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest'; import { createRegistry } from '../registry'; import { buildSchemas } from '../schemas'; import { TypType } from '../types/typ'; +import { LocalScope } from '../type-scope'; /** * TypType — a gin type whose runtime values ARE TypeDefs. Its generic T @@ -79,12 +80,15 @@ describe('TypType', () => { expect(r.typ(r.num()).compatible(r.num())).toBe(false); }); - test('generic binding: typ.bind({R: num}) → typ', () => { + test('generic resolution: typ with extra-scope R=num behaves as typ', () => { const r = createRegistry(); - const t = r.typ(r.generic('R')); - const bound = t.bind({ R: r.num() }) as TypType; - expect(bound).toBeInstanceOf(TypType); - expect(bound.constraint.name).toBe('num'); + const t = r.typ(r.alias('R')); + const local = new LocalScope(r, { R: r.num() }); + // typ.parse({name:'num'}, local) — R resolves to num via the + // extra scope, so num is a satisfying TypeDef. + expect(t.parse({ name: 'num' }, local).raw.name).toBe('num'); + // typ.parse({name:'text'}, local) — text is not num-compatible. + expect(() => t.parse({ name: 'text' }, local)).toThrow(); }); test('typ parse accepts any TypeDef JSON', () => { @@ -140,7 +144,7 @@ describe('TypType', () => { describe('TypType + generics', () => { test('unbound typ acts as typ — parse accepts any TypeDef', () => { const r = createRegistry(); - const t = r.typ(r.generic('R')); + const t = r.typ(r.alias('R')); expect(t.parse({ name: 'num' }).raw.name).toBe('num'); expect(t.parse({ name: 'text' }).raw.name).toBe('text'); expect(t.parse({ name: 'list', generic: { V: { name: 'num' } } }).raw.name).toBe('list'); @@ -148,55 +152,52 @@ describe('TypType + generics', () => { expect(() => t.parse(42)).toThrow(); }); - test('typ.bind({R: num}) narrows to typ', () => { + test('typ with extra-scope R=num accepts num and rejects text', () => { const r = createRegistry(); - const unbound = r.typ(r.generic('R')); - const bound = unbound.bind({ R: r.num() }) as TypType; - expect(bound).toBeInstanceOf(TypType); - expect(bound.constraint.name).toBe('num'); - expect(bound.parse({ name: 'num' }).raw.name).toBe('num'); - expect(() => bound.parse({ name: 'text' })).toThrow(); + const unbound = r.typ(r.alias('R')); + const local = new LocalScope(r, { R: r.num() }); + expect(unbound.parse({ name: 'num' }, local).raw.name).toBe('num'); + expect(() => unbound.parse({ name: 'text' }, local)).toThrow(); }); - test('fn<(args{output: typ}), R>.bind({R: num}) narrows output AND return', () => { + test('fn<(args{output: typ}), R> with R=num scope: returns resolves to num, output to optional>', () => { const r = createRegistry(); const fn = r.fn( - r.obj({ output: { type: r.optional(r.typ(r.generic('R'))) } }), - r.generic('R'), + r.obj({ output: { type: r.optional(r.typ(r.alias('R'))) } }), + r.alias('R'), undefined, { R: r.any() }, ); - const bound = fn.bind({ R: r.num() }); - const call = bound.call(); + const local = new LocalScope(r, { R: r.num() }); + const call = fn.call(local); expect(call).toBeTruthy(); - // Return type narrowed to num. - expect(call!.returns?.name).toBe('num'); - // output arg narrowed to optional>. + // Return type's resolved form is num via simplify(local). + expect(call!.returns?.simplify(local).name).toBe('num'); + // output arg is optional> — outer Optional doesn't change. const argsType = call!.args; - const outputProp = argsType.prop('output'); + const outputProp = argsType.prop('output', local); expect(outputProp).toBeTruthy(); expect(outputProp!.type.name).toBe('optional'); }); test('typ JSON round-trips preserving the generic placeholder', () => { const r = createRegistry(); - const t = r.typ(r.generic('R')); + const t = r.typ(r.alias('R')); const json = t.toJSON(); expect(json.name).toBe('typ'); - expect(json.generic?.T).toEqual({ name: 'generic', options: { name: 'R' } }); + // Bare-name form — AliasType.toJSON emits `{name: 'R'}`. + expect(json.generic?.T).toEqual({ name: 'R' }); const back = r.parse(json) as TypType; expect(back).toBeInstanceOf(TypType); - expect(back.constraint.name).toBe('generic'); + expect(back.constraint.name).toBe('alias'); }); - test('typ>.bind({R: num}) narrows list item', () => { + test('typ> with extra-scope R=num: list ok, list rejected', () => { const r = createRegistry(); - const t = r.typ(r.list(r.generic('R'))); - const bound = t.bind({ R: r.num() }) as TypType; - expect(bound.constraint.name).toBe('list'); - // Accepts list TypeDef; rejects list. - expect(bound.parse({ name: 'list', generic: { V: { name: 'num' } } }).raw.name).toBe('list'); - expect(() => bound.parse({ name: 'list', generic: { V: { name: 'text' } } })).toThrow(); + const t = r.typ(r.list(r.alias('R'))); + const local = new LocalScope(r, { R: r.num() }); + expect(t.parse({ name: 'list', generic: { V: { name: 'num' } } }, local).raw.name).toBe('list'); + expect(() => t.parse({ name: 'list', generic: { V: { name: 'text' } } }, local)).toThrow(); }); }); diff --git a/packages/gin/src/analysis.ts b/packages/gin/src/analysis.ts index 25ea7bf..4012887 100644 --- a/packages/gin/src/analysis.ts +++ b/packages/gin/src/analysis.ts @@ -9,13 +9,13 @@ import { RESERVED_NAMES } from './scope'; * Static type scope: name → runtime Type. Used by typeOf / validate to * reason about expression trees without executing them. */ -export type TypeScope = Map; +export type Locals = Map; /** * Infer the static result Type of an ExprDef (or parsed Expr) against a * type scope. Falls back to `any` on unknown parts — never throws. */ -export function typeOf(engine: Engine, expr: ExprDef | Expr, scope: TypeScope): Type { +export function typeOf(engine: Engine, expr: ExprDef | Expr, scope: Locals): Type { const e = expr instanceof Expr ? expr : parseExprSafe(engine, expr); if (!e) return engine.registry.any(); return e.typeOf(engine, scope); @@ -27,7 +27,7 @@ function parseExprSafe(engine: Engine, expr: ExprDef): Expr | undefined { } /** Top-level: walk an expression tree collecting Problems. Never throws. */ -export function validate(engine: Engine, expr: ExprDef | Expr, scope: TypeScope): Problems { +export function validate(engine: Engine, expr: ExprDef | Expr, scope: Locals): Problems { const p = new Problems(); walkValidate(engine, expr, scope, p, { inLoop: false, inLambda: false }); return p; @@ -37,7 +37,7 @@ export function validate(engine: Engine, expr: ExprDef | Expr, scope: TypeScope) export function walkValidate( engine: Engine, expr: ExprDef | Expr, - scope: TypeScope, + scope: Locals, p: Problems, ctx: ValidateContext, ): Type { @@ -75,7 +75,7 @@ export type { ValidateContext } from './expr'; */ export function checkBindingName( name: string, - scope: TypeScope, + scope: Locals, p: Problems, ): void { if (RESERVED_NAMES.has(name)) { diff --git a/packages/gin/src/builder.ts b/packages/gin/src/builder.ts index 6d4daf4..0a4472f 100644 --- a/packages/gin/src/builder.ts +++ b/packages/gin/src/builder.ts @@ -138,9 +138,8 @@ export interface TypeBuilder { // ─── interfaces ───────────────────────────────────────────────────────── iface(spec: IfaceSpec): Type; - // ─── references & generics ────────────────────────────────────────────── - ref(name: string): Type; - generic(name: string): Type; + // ─── aliases (former ref + generic, unified) ──────────────────────────── + alias(name: string): Type; // ─── extension ────────────────────────────────────────────────────────── extend(base: Type | string, local: ExtensionLocal): Extension; diff --git a/packages/gin/src/engine.ts b/packages/gin/src/engine.ts index e9a2388..06cbbc8 100644 --- a/packages/gin/src/engine.ts +++ b/packages/gin/src/engine.ts @@ -9,7 +9,7 @@ import { Expr } from './expr'; import type { CodeOptions } from './node'; import { ExitSignal } from './flow-control'; -import { typeOf as typeOfAnalysis, validate as validateAnalysis, type TypeScope } from './analysis'; +import { typeOf as typeOfAnalysis, validate as validateAnalysis, type Locals } from './analysis'; import { Problems } from './problem'; /** @@ -87,7 +87,7 @@ export class Engine { * Infer the static return type of an expression against a type scope. * Returns `any` on unknown parts — never throws. */ - typeOf(expr: ExprDef | Expr, scope?: TypeScope): Type { + typeOf(expr: ExprDef | Expr, scope?: Locals): Type { const s = scope ?? this.globalTypeScope(); return typeOfAnalysis(this, expr, s); } @@ -96,7 +96,7 @@ export class Engine { * Walk an expression tree and collect Problems (unknown vars, unknown * props / natives, out-of-place break/return, etc.). Never throws. */ - validate(expr: ExprDef | Expr, scope?: TypeScope): Problems { + validate(expr: ExprDef | Expr, scope?: Locals): Problems { const s = scope ?? this.globalTypeScope(); return validateAnalysis(this, expr, s); } @@ -109,8 +109,8 @@ export class Engine { return this.registry.toCode(expr, options); } - /** A TypeScope seeded with the registered globals' declared types. */ - globalTypeScope(): TypeScope { + /** A Locals seeded with the registered globals' declared types. */ + globalTypeScope(): Locals { const m = new Map(); for (const [name, g] of this.globals) m.set(name, g.type); return m; diff --git a/packages/gin/src/expr.ts b/packages/gin/src/expr.ts index 11fcd7f..b76a20c 100644 --- a/packages/gin/src/expr.ts +++ b/packages/gin/src/expr.ts @@ -4,10 +4,11 @@ import type { Value } from './value'; import type { Type } from './type'; import type { Registry } from './registry'; import type { ExprDef } from './schema'; -import type { TypeScope } from './analysis'; +import type { Locals } from './analysis'; import { Problems } from './problem'; import type { Node, CodeOptions, SchemaOptions } from './node'; import type { z } from 'zod'; +import type { TypeScope } from './type-scope'; /** * Context flags carried through validate walks so handlers can report @@ -80,17 +81,17 @@ export abstract class Expr implements Node { abstract evaluate(engine: Engine, scope: Scope): Promise; /** Infer the static return Type against a type scope. */ - abstract typeOf(engine: Engine, scope: TypeScope): Type; + abstract typeOf(engine: Engine, scope: Locals): Type; /** * Recursive validation walk — accumulates Problems into `p` and returns * the inferred Type. Called by child exprs during validate walks. * Use `validate(engine)` for the clean top-level entry. */ - abstract validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type; + abstract validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type; /** Top-level entry: walk collecting Problems. Mirrors Type.validate. */ - validate(engine: Engine, scope?: TypeScope): Problems { + validate(engine: Engine, scope?: Locals): Problems { const p = new Problems(); const s = scope ?? engine.globalTypeScope(); this.validateWalk(engine, s, p, { inLoop: false, inLambda: false }); @@ -121,7 +122,11 @@ export abstract class Expr implements Node { */ export interface ExprClass { readonly KIND: string; - from(json: ExprDef, registry: Registry): Expr; + /** Build an Expr from its JSON. `scope` is the type-name resolution + * scope used when recursing into nested TypeDefs (for `new`, + * `lambda`, `native`, `define`). Use `scope.registry` to access the + * underlying Registry. */ + from(json: ExprDef, scope: TypeScope): Expr; /** JSON-shape Zod schema for this Expr's ExprDef. */ toSchema(opts: SchemaOptions): z.ZodTypeAny; } diff --git a/packages/gin/src/exprs/block.ts b/packages/gin/src/exprs/block.ts index e11cdcf..ddf91c8 100644 --- a/packages/gin/src/exprs/block.ts +++ b/packages/gin/src/exprs/block.ts @@ -4,7 +4,7 @@ import type { BlockExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; @@ -12,6 +12,7 @@ import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode } from './code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * BlockExpr — sequence of expressions; last one's value is the result. @@ -24,8 +25,9 @@ export class BlockExpr extends Expr { super(); } - static from(json: BlockExprDef, registry: Registry): BlockExpr { - return new BlockExpr(json.lines.map((l) => registry.parseExpr(l))).withComment(json.comment); + static from(json: BlockExprDef, scope: TypeScope): BlockExpr { + const r = scope.registry; + return new BlockExpr(json.lines.map((l) => r.parseExpr(l, scope))).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -48,12 +50,12 @@ export class BlockExpr extends Expr { return last; } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { if (this.lines.length === 0) return engine.registry.void(); return typeOf(engine, this.lines[this.lines.length - 1]!, scope); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { let last: Type = engine.registry.void(); for (let i = 0; i < this.lines.length; i++) { last = p.at(i, () => walkValidate(engine, this.lines[i]!, scope, p, ctx)); diff --git a/packages/gin/src/exprs/define.ts b/packages/gin/src/exprs/define.ts index dd548ca..243d53b 100644 --- a/packages/gin/src/exprs/define.ts +++ b/packages/gin/src/exprs/define.ts @@ -4,7 +4,7 @@ import type { DefineExprDef, TypeDef } from '../schema'; import type { Value } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { checkBindingName, typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; @@ -12,6 +12,7 @@ import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode } from './code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * DefineExpr — introduce local bindings into a child scope, then evaluate body. @@ -32,13 +33,14 @@ export class DefineExpr extends Expr { super(); } - static from(json: DefineExprDef, registry: Registry): DefineExpr { + static from(json: DefineExprDef, scope: TypeScope): DefineExpr { + const r = scope.registry; const vars: DefineVar[] = json.vars.map((v) => ({ name: v.name, - type: v.type ? registry.parse(v.type) : undefined, - value: registry.parseExpr(v.value), + type: v.type ? r.parse(v.type, scope) : undefined, + value: r.parseExpr(v.value, scope), })); - return new DefineExpr(vars, registry.parseExpr(json.body)).withComment(json.comment); + return new DefineExpr(vars, r.parseExpr(json.body, scope)).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -73,8 +75,8 @@ export class DefineExpr extends Expr { return this.body.evaluate(engine, child); } - typeOf(engine: Engine, scope: TypeScope): Type { - const child: TypeScope = new Map(scope); + typeOf(engine: Engine, scope: Locals): Type { + const child: Locals = new Map(scope); for (const v of this.vars) { const t = v.type ?? typeOf(engine, v.value, child); child.set(v.name, t); @@ -82,8 +84,8 @@ export class DefineExpr extends Expr { return typeOf(engine, this.body, child); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { - const child: TypeScope = new Map(scope); + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { + const child: Locals = new Map(scope); for (let i = 0; i < this.vars.length; i++) { const v = this.vars[i]!; // Each var name must not be a reserved name and must not collide diff --git a/packages/gin/src/exprs/flow.ts b/packages/gin/src/exprs/flow.ts index f4940a8..0b30fb0 100644 --- a/packages/gin/src/exprs/flow.ts +++ b/packages/gin/src/exprs/flow.ts @@ -5,13 +5,14 @@ import type { Value } from '../value'; import { BreakSignal, ContinueSignal, ExitSignal, ReturnSignal, ThrowSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export type FlowAction = 'break' | 'return' | 'continue' | 'exit' | 'throw'; @@ -30,11 +31,12 @@ export class FlowExpr extends Expr { super(); } - static from(json: FlowExprDef, registry: Registry): FlowExpr { + static from(json: FlowExprDef, scope: TypeScope): FlowExpr { + const r = scope.registry; return new FlowExpr( json.action, - json.value ? registry.parseExpr(json.value) : undefined, - json.error ? registry.parseExpr(json.error) : undefined, + json.value ? r.parseExpr(json.value, scope) : undefined, + json.error ? r.parseExpr(json.error, scope) : undefined, ).withComment(json.comment); } @@ -78,11 +80,11 @@ export class FlowExpr extends Expr { } } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return engine.registry.void(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { if ((this.action === 'break' || this.action === 'continue') && !ctx.inLoop) { p.error('flow.outside-loop', `${this.action} used outside a loop`); } diff --git a/packages/gin/src/exprs/get.ts b/packages/gin/src/exprs/get.ts index 72e473e..48c9d75 100644 --- a/packages/gin/src/exprs/get.ts +++ b/packages/gin/src/exprs/get.ts @@ -5,12 +5,13 @@ import type { Value } from '../value'; import { Path } from '../path'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields, pathStepSchema } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * GetExpr — read a value through a Path chain. @@ -24,8 +25,8 @@ export class GetExpr extends Expr { super(); } - static from(json: GetExprDef, registry: Registry): GetExpr { - return new GetExpr(Path.from(json.path, registry)).withComment(json.comment); + static from(json: GetExprDef, scope: TypeScope): GetExpr { + return new GetExpr(Path.from(json.path, scope)).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -44,11 +45,11 @@ export class GetExpr extends Expr { return this.path.walk(scope, _engine, { mode: 'get' }); } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { return this.path.typeOf(engine, scope); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { return this.path.validateWalk(engine, scope, p, ctx, 'get'); } diff --git a/packages/gin/src/exprs/if.ts b/packages/gin/src/exprs/if.ts index 83236c2..1450750 100644 --- a/packages/gin/src/exprs/if.ts +++ b/packages/gin/src/exprs/if.ts @@ -4,7 +4,7 @@ import type { IfExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; @@ -12,6 +12,7 @@ import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode, renderStatementBody, findEscapingFlow } from './code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export interface IfBranch { condition: Expr; @@ -29,12 +30,13 @@ export class IfExpr extends Expr { super(); } - static from(json: IfExprDef, registry: Registry): IfExpr { + static from(json: IfExprDef, scope: TypeScope): IfExpr { + const r = scope.registry; const ifs = json.ifs.map((b) => ({ - condition: registry.parseExpr(b.condition), - body: registry.parseExpr(b.body), + condition: r.parseExpr(b.condition, scope), + body: r.parseExpr(b.body, scope), })); - return new IfExpr(ifs, json.else ? registry.parseExpr(json.else) : undefined) + return new IfExpr(ifs, json.else ? r.parseExpr(json.else, scope) : undefined) .withComment(json.comment); } @@ -65,13 +67,13 @@ export class IfExpr extends Expr { return val(engine.registry.void(), undefined); } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { const ts = this.ifs.map((b) => typeOf(engine, b.body, scope)); if (this.otherwise) ts.push(typeOf(engine, this.otherwise, scope)); return ts.length === 1 ? ts[0]! : engine.registry.or(ts); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const bool = engine.registry.bool(); const ts: Type[] = []; for (let i = 0; i < this.ifs.length; i++) { diff --git a/packages/gin/src/exprs/inline-aliases.ts b/packages/gin/src/exprs/inline-aliases.ts deleted file mode 100644 index 77dc3db..0000000 --- a/packages/gin/src/exprs/inline-aliases.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Call-level type-alias inliner. - * - * `CallDef.types` declares a sequential map of locally-scoped TypeDefs - * that resolve in `args` / `returns` / `throws` / `get` / `set` via - * bare `{name: ''}` references. This module's two walkers - * substitute those references with deep clones of the alias's source, - * producing fully-inlined TypeDefs/ExprDefs that the existing decoders - * (`decodeCall`, `registry.parse`) can consume without scope plumbing. - * - * Both walkers are pure JSON-in / JSON-out — no registry access, no - * Type construction. Field shapes come from `schema.ts`. - * - * Why this rather than reusing `spec.ts:substituteChildren`? That - * helper round-trips through `registry.parse(def).substitute().toJSON()` - * (`spec.ts:19-25`). On a TypeDef containing an alias name not in the - * registry, `registry.parse` throws before any rewriting can happen. - * It also misses several slots we need (constraint, init.run, ExprDef - * trees inside call.get/set / new.type / lambda / native / define). - */ -import type { - CallDef, - ExprDef, - PathDef, - PathStepDef, - PathCallDef, - PathIndexDef, - TypeDef, -} from '../schema'; -import type { Registry } from '../registry'; -import { TypeError } from '../problem'; - -export type AliasMap = Record; - -const ALIAS_REF_DISALLOWED_PEERS: ReadonlyArray = [ - 'extends', 'satisfies', 'generic', 'options', 'init', - 'props', 'get', 'call', 'constraint', -]; - -/** True if `def` is a bare alias reference: `{name}` with optional - * `docs` only, and `name` is in `aliases`. Any structural peer - * (extends/options/generic/etc.) means it's NOT a ref — let the - * registry/type machinery handle it normally. */ -function isAliasRef(def: TypeDef, aliases: AliasMap): boolean { - if (!aliases[def.name]) return false; - for (const k of ALIAS_REF_DISALLOWED_PEERS) { - if ((def as unknown as Record)[k] !== undefined) return false; - } - return true; -} - -/** Deep-clone a TypeDef. Used so each inlined site is independent — - * later substitution (.bind) on one site doesn't bleed into others. */ -function cloneTypeDef(def: TypeDef): TypeDef { - return JSON.parse(JSON.stringify(def)) as TypeDef; -} - -/** - * Walk a TypeDef substituting bare alias references with deep clones - * of the alias's source. Recurses through every TypeDef-bearing slot. - */ -export function inlineTypeDef(def: TypeDef, aliases: AliasMap): TypeDef { - if (isAliasRef(def, aliases)) { - return cloneTypeDef(aliases[def.name]!); - } - - const next: TypeDef = { ...def }; - - // Defensive: an alias name appearing in `extends` or `satisfies` is a - // user error — aliases can't be extended; aliases must be referenced - // by the bare `{name}` shape. - if (def.extends && aliases[def.extends]) { - throw new TypeError({ - path: ['extends'], - code: 'call.types.extends-alias', - message: `'${def.extends}' is a call-local type alias and cannot be used as 'extends'. Use a registered named type, or write the alias's def inline.`, - severity: 'error', - }); - } - if (def.satisfies) { - for (const s of def.satisfies) { - if (aliases[s]) { - throw new TypeError({ - path: ['satisfies'], - code: 'call.types.extends-alias', - message: `'${s}' is a call-local type alias and cannot be used in 'satisfies'.`, - severity: 'error', - }); - } - } - } - - if (def.generic) { - const g: Record = {}; - for (const [k, v] of Object.entries(def.generic)) { - g[k] = inlineTypeDef(v, aliases); - } - next.generic = g; - } - - // `or<...>` carries its variants on `options.types`, not on the - // standard `generic`/`props` slots. Walk them so an alias used in - // `or` resolves correctly. - if (def.options && Array.isArray((def.options as { types?: TypeDef[] }).types)) { - const options = { ...(def.options as Record) }; - options['types'] = ((def.options as { types: TypeDef[] }).types).map((t) => inlineTypeDef(t, aliases)); - next.options = options; - } - - if (def.props) { - const p: Record[string]> = {}; - for (const [k, pd] of Object.entries(def.props)) { - p[k] = { - ...pd, - type: inlineTypeDef(pd.type, aliases), - get: pd.get ? inlineExprDef(pd.get, aliases) : undefined, - set: pd.set ? inlineExprDef(pd.set, aliases) : undefined, - default: pd.default ? inlineExprDef(pd.default, aliases) : undefined, - }; - } - next.props = p; - } - - if (def.get) { - next.get = { - ...def.get, - key: inlineTypeDef(def.get.key, aliases), - value: inlineTypeDef(def.get.value, aliases), - get: def.get.get ? inlineExprDef(def.get.get, aliases) : undefined, - set: def.get.set ? inlineExprDef(def.get.set, aliases) : undefined, - loop: def.get.loop ? inlineExprDef(def.get.loop, aliases) : undefined, - }; - } - - if (def.call) { - next.call = { - ...def.call, - args: inlineTypeDef(def.call.args, aliases), - returns: def.call.returns ? inlineTypeDef(def.call.returns, aliases) : undefined, - throws: def.call.throws ? inlineTypeDef(def.call.throws, aliases) : undefined, - get: def.call.get ? inlineExprDef(def.call.get, aliases) : undefined, - set: def.call.set ? inlineExprDef(def.call.set, aliases) : undefined, - // NOTE: a NESTED call's own `types` map is its own scope. Don't - // strip it here; let the inner `decodeCall` process it. Outer - // aliases STILL inline into inner non-types slots, which is the - // expected behavior — outer aliases are visible inside inner - // until the inner shadows. - }; - } - - if (def.init) { - next.init = { - ...def.init, - args: inlineTypeDef(def.init.args, aliases), - run: inlineExprDef(def.init.run, aliases), - }; - } - - if (def.constraint) { - next.constraint = inlineExprDef(def.constraint, aliases); - } - - return next; -} - -/** - * Walk an ExprDef substituting alias references inside any embedded - * TypeDefs. Recurses into child ExprDefs. - */ -export function inlineExprDef(expr: ExprDef, aliases: AliasMap): ExprDef { - const next: ExprDef = { ...expr }; - - switch (expr.kind) { - case 'new': { - const e = expr as ExprDef & { type: TypeDef; value?: unknown }; - next['type'] = inlineTypeDef(e.type, aliases); - // `value` may itself contain ExprDefs (e.g. for new list / new - // obj — slots are Exprs). Recurse via a permissive walker. - if (e.value !== undefined) next['value'] = inlineNewValue(e.value, aliases); - return next; - } - case 'lambda': { - const e = expr as ExprDef & { type: TypeDef; body: ExprDef; constraint?: ExprDef }; - next['type'] = inlineTypeDef(e.type, aliases); - next['body'] = inlineExprDef(e.body, aliases); - if (e.constraint) next['constraint'] = inlineExprDef(e.constraint, aliases); - return next; - } - case 'native': { - const e = expr as ExprDef & { type?: TypeDef }; - if (e.type) next['type'] = inlineTypeDef(e.type, aliases); - return next; - } - case 'define': { - const e = expr as ExprDef & { vars: Array<{ name: string; type?: TypeDef; value: ExprDef }>; body: ExprDef }; - next['vars'] = e.vars.map((v) => ({ - name: v.name, - type: v.type ? inlineTypeDef(v.type, aliases) : undefined, - value: inlineExprDef(v.value, aliases), - })); - next['body'] = inlineExprDef(e.body, aliases); - return next; - } - case 'block': { - const e = expr as ExprDef & { lines: ExprDef[] }; - next['lines'] = e.lines.map((l) => inlineExprDef(l, aliases)); - return next; - } - case 'if': { - const e = expr as ExprDef & { ifs: Array<{ condition: ExprDef; body: ExprDef }>; else?: ExprDef }; - next['ifs'] = e.ifs.map((b) => ({ - condition: inlineExprDef(b.condition, aliases), - body: inlineExprDef(b.body, aliases), - })); - if (e.else) next['else'] = inlineExprDef(e.else, aliases); - return next; - } - case 'switch': { - const e = expr as ExprDef & { value: ExprDef; cases: Array<{ equals: ExprDef[]; body: ExprDef }>; else?: ExprDef }; - next['value'] = inlineExprDef(e.value, aliases); - next['cases'] = e.cases.map((c) => ({ - equals: c.equals.map((eq) => inlineExprDef(eq, aliases)), - body: inlineExprDef(c.body, aliases), - })); - if (e.else) next['else'] = inlineExprDef(e.else, aliases); - return next; - } - case 'loop': { - const e = expr as ExprDef & { over: ExprDef; body: ExprDef; parallel?: { concurrent?: ExprDef; rate?: ExprDef } }; - next['over'] = inlineExprDef(e.over, aliases); - next['body'] = inlineExprDef(e.body, aliases); - if (e.parallel) { - next['parallel'] = { - concurrent: e.parallel.concurrent ? inlineExprDef(e.parallel.concurrent, aliases) : undefined, - rate: e.parallel.rate ? inlineExprDef(e.parallel.rate, aliases) : undefined, - }; - } - return next; - } - case 'template': { - const e = expr as ExprDef & { template: unknown; params: ExprDef }; - // template can be a string OR an ExprDef. Inline only when it's - // an Expr-shaped object. - if (e.template && typeof e.template === 'object' && 'kind' in (e.template as object)) { - next['template'] = inlineExprDef(e.template as ExprDef, aliases); - } - next['params'] = inlineExprDef(e.params, aliases); - return next; - } - case 'flow': { - const e = expr as ExprDef & { value?: ExprDef; error?: ExprDef }; - if (e.value) next['value'] = inlineExprDef(e.value, aliases); - if (e.error) next['error'] = inlineExprDef(e.error, aliases); - return next; - } - case 'set': { - const e = expr as ExprDef & { path: PathDef; value: ExprDef }; - next['path'] = inlinePath(e.path, aliases); - next['value'] = inlineExprDef(e.value, aliases); - return next; - } - case 'get': { - const e = expr as ExprDef & { path: PathDef }; - next['path'] = inlinePath(e.path, aliases); - return next; - } - default: - // Unknown kind — leave as-is. New expr kinds added later should - // teach this walker about their TypeDef-bearing slots. - return next; - } -} - -/** PathDef step list — `args` map of ExprDefs, `generic` map of TypeDefs, - * `key` ExprDef, `catch` ExprDef. Walks all of them. */ -function inlinePath(path: PathDef, aliases: AliasMap): PathDef { - return path.map((step) => inlinePathStep(step, aliases)); -} - -function inlinePathStep(step: PathStepDef, aliases: AliasMap): PathStepDef { - if ('prop' in step) return step; - if ('args' in step) { - const c = step as PathCallDef; - const out: PathCallDef = { - args: Object.fromEntries( - Object.entries(c.args).map(([k, v]) => [k, inlineExprDef(v, aliases)]), - ), - }; - if (c.generic) { - out.generic = Object.fromEntries( - Object.entries(c.generic).map(([k, v]) => [k, inlineTypeDef(v, aliases)]), - ); - } - if (c.catch) out.catch = inlineExprDef(c.catch, aliases); - return out; - } - // index step - const i = step as PathIndexDef; - return { key: inlineExprDef(i.key, aliases) }; -} - -/** - * `new.value` is a permissive shape — it depends on the type's - * `toNewSchema`. Composites accept Expr slots; primitives accept raw - * values. We only need to recurse where the value LOOKS like an - * ExprDef (object with `kind`) or where it's a structure containing - * ExprDefs (arrays for list-new, records for obj-new). - */ -function inlineNewValue(value: unknown, aliases: AliasMap): unknown { - if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) { - return value.map((v) => inlineNewValue(v, aliases)); - } - if ('kind' in (value as object) && typeof (value as { kind: unknown }).kind === 'string') { - return inlineExprDef(value as ExprDef, aliases); - } - // Plain record (e.g. obj-new value) — recurse into its values. - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = inlineNewValue(v, aliases); - } - return out; -} - -/** - * Validate alias names BEFORE inlining begins. Throws `TypeError` with - * a namespaced code on the first offence — matches `decodeCall`'s - * existing throw-on-structural-error convention. - */ -export function validateAliasNames(names: ReadonlyArray, registry: Registry): void { - const classNames = new Set(registry.typeClasses().map((c) => c.NAME)); - for (const name of names) { - if (name === '') { - throw new TypeError({ - path: ['types'], - code: 'call.types.empty-name', - message: 'call.types alias name cannot be empty', - severity: 'error', - }); - } - if (classNames.has(name)) { - throw new TypeError({ - path: ['types', name], - code: 'call.types.name-conflict', - message: `call.types alias '${name}' shadows a built-in type class — pick a different name`, - severity: 'error', - }); - } - } -} - -/** - * Build the alias source map for a CallDef. Each alias's value is - * `inlineTypeDef(def, prevAliases)` so later aliases see earlier ones. - * Returns an empty map if `types` is undefined or empty. - * - * Forward / self references are caught implicitly: if alias B - * references alias A and A hasn't been declared yet, the bare-ref - * lookup misses (returns undefined) and the bare ref survives into - * the parsed output, where `registry.parse` then throws "unknown - * type" — but that error is opaque. We surface a clearer one by - * checking after the inline pass. - */ -export function buildAliasMap(types: Record | undefined, registry: Registry): AliasMap { - if (!types) return {}; - const names = Object.keys(types); - validateAliasNames(names, registry); - const allNames = new Set(names); - - const aliases: AliasMap = {}; - for (const name of names) { - const inlined = inlineTypeDef(types[name]!, aliases); - // After inlining-against-prior, any surviving bare reference to a - // name that's ALSO in the full alias name set is a forward (or - // self) reference — that ref couldn't be resolved because its - // target hadn't been declared yet. Refs to names outside the - // alias set fall through to the registry at parse time, which - // throws its own "unknown type" — leave those alone here. - const offending = findBareRefToAny(inlined, allNames); - if (offending) { - throw new TypeError({ - path: ['types', name], - code: 'call.types.forward-ref', - message: `call.types alias '${name}' references '${offending}' before it's declared (or itself) — declare prerequisites earlier in the types map`, - severity: 'error', - }); - } - aliases[name] = inlined; - } - return aliases; -} - -/** Walk `def` and return the first bare-ref name (if any) that - * appears in `names`. Used by `buildAliasMap` to detect forward / - * self references after each alias has been inlined-against-prior: - * a surviving bare ref to a name ALSO declared in the alias set is - * necessarily one that wasn't yet declared at inline time. */ -function findBareRefToAny(def: TypeDef, names: ReadonlySet): string | undefined { - if (names.has(def.name)) { - let bare = true; - for (const k of ALIAS_REF_DISALLOWED_PEERS) { - if ((def as unknown as Record)[k] !== undefined) { bare = false; break; } - } - if (bare) return def.name; - } - if (def.generic) { - for (const v of Object.values(def.generic)) { - const f = findBareRefToAny(v, names); if (f) return f; - } - } - if (def.props) { - for (const pd of Object.values(def.props)) { - const f = findBareRefToAny(pd.type, names); if (f) return f; - } - } - if (def.call) { - let f = findBareRefToAny(def.call.args, names); if (f) return f; - if (def.call.returns) { f = findBareRefToAny(def.call.returns, names); if (f) return f; } - if (def.call.throws) { f = findBareRefToAny(def.call.throws, names); if (f) return f; } - } - if (def.init) { - const f = findBareRefToAny(def.init.args, names); if (f) return f; - } - if (def.get) { - let f = findBareRefToAny(def.get.key, names); if (f) return f; - f = findBareRefToAny(def.get.value, names); if (f) return f; - } - if (def.options && Array.isArray((def.options as { types?: TypeDef[] }).types)) { - for (const t of (def.options as { types: TypeDef[] }).types) { - const f = findBareRefToAny(t, names); if (f) return f; - } - } - return undefined; -} - -/** Inline an entire CallDef's slots against the supplied alias map. - * Returns a new CallDef WITHOUT the `types` field (inlined output is - * alias-free). Source CallDef is not mutated. */ -export function inlineCallDef(def: CallDef, aliases: AliasMap): CallDef { - return { - docs: def.docs, - args: inlineTypeDef(def.args, aliases), - returns: def.returns ? inlineTypeDef(def.returns, aliases) : undefined, - throws: def.throws ? inlineTypeDef(def.throws, aliases) : undefined, - get: def.get ? inlineExprDef(def.get, aliases) : undefined, - set: def.set ? inlineExprDef(def.set, aliases) : undefined, - }; -} diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index 60d81e5..4fdf80a 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -1,18 +1,18 @@ import type { Engine } from '../engine'; import type { Scope } from '../scope'; -import type { ExprDef, LambdaExprDef, TypeDef } from '../schema'; +import type { LambdaExprDef } from '../schema'; import { Value } from '../value'; import { ReturnSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; -import { buildAliasMap, inlineExprDef } from './inline-aliases'; +import { LocalScope, type TypeScope } from '../type-scope'; /** * LambdaExpr — a callable value that closes over the lexical scope. @@ -29,42 +29,23 @@ export class LambdaExpr extends Expr { readonly fnType: Type, readonly body: Expr, readonly constraint?: Expr, - /** Source-form ExprDefs (with alias references intact) for - * round-trip via `toJSON()` when `fnType.call.types` declared - * call-local aliases. Without these the parsed body's `toJSON()` - * would emit the inlined form, defeating the verbosity-reduction - * point of aliases. */ - readonly _sourceBody?: ExprDef, - readonly _sourceConstraint?: ExprDef, ) { super(); } - static from(json: LambdaExprDef, registry: Registry): LambdaExpr { - // If the lambda's fnType declares `call.types`, those aliases must - // resolve in the body / constraint too — same convention as a - // saved fn's `call.get` / `call.set` (see `decodeCall`). Inline - // before parsing so referenced alias names don't trip - // `registry.parse`'s "unknown type" check. - const callTypes = (json.type as { call?: { types?: Record } }) - .call?.types; - const aliases = callTypes ? buildAliasMap(callTypes, registry) : undefined; - const hasAliases = !!aliases && Object.keys(aliases).length > 0; - const bodyDef = hasAliases ? inlineExprDef(json.body, aliases!) : json.body; - const constraintDef = json.constraint - ? (hasAliases ? inlineExprDef(json.constraint, aliases!) : json.constraint) + static from(json: LambdaExprDef, scope: TypeScope): LambdaExpr { + const registry = scope.registry; + // Parse the fn type first (FnType.from layers its own LocalScope + // for declared generics). Then build a body scope on top so bare + // alias / generic references inside the body / constraint resolve + // through AliasType. + const fnType = registry.parse(json.type, scope); + const bodyScope = buildBodyScope(scope, fnType); + const body = registry.parseExpr(json.body, bodyScope); + const constraint = json.constraint + ? registry.parseExpr(json.constraint, bodyScope) : undefined; - - const fnType = registry.parse(json.type); - const body = registry.parseExpr(bodyDef); - const constraint = constraintDef ? registry.parseExpr(constraintDef) : undefined; - return new LambdaExpr( - fnType, - body, - constraint, - hasAliases ? json.body : undefined, - hasAliases && json.constraint ? json.constraint : undefined, - ).withComment(json.comment); + return new LambdaExpr(fnType, body, constraint).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -113,13 +94,13 @@ export class LambdaExpr extends Expr { return new Value(fnType, callable); } - typeOf(_engine: Engine, _scope: TypeScope): Type { + typeOf(_engine: Engine, _scope: Locals): Type { return this.fnType; } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const call = this.fnType.call(); - const child: TypeScope = new Map(scope); + const child: Locals = new Map(scope); child.set('args', call?.args ?? engine.registry.any()); const bodyT = p.at('body', () => walkValidate(engine, this.body, child, p, { ...ctx, inLambda: true })); @@ -160,11 +141,8 @@ export class LambdaExpr extends Expr { return this.withCommentOn({ kind: 'lambda', type: this.fnType.toJSON(), - // Prefer source forms when present so aliased bodies round-trip - // compact. The parsed body has already been inlined; emitting - // it would lose the alias references the user wrote. - body: this._sourceBody ?? this.body.toJSON(), - constraint: this._sourceConstraint ?? this.constraint?.toJSON(), + body: this.body.toJSON(), + constraint: this.constraint?.toJSON(), }); } @@ -173,8 +151,6 @@ export class LambdaExpr extends Expr { this.fnType.clone(), this.body.clone(), this.constraint?.clone(), - this._sourceBody, - this._sourceConstraint, ).withComment(this.comment); } @@ -183,3 +159,16 @@ export class LambdaExpr extends Expr { if (this.constraint) visit(this.constraint, 'lambda'); } } + +/** Build a body scope that exposes the fnType's generics and + * `call.types` aliases by name, so bare `{name: 'X'}` references + * inside the body / constraint resolve via AliasType. */ +function buildBodyScope(parent: TypeScope, fnType: Type): TypeScope { + const local = new LocalScope(parent); + for (const [name, t] of Object.entries(fnType.generic)) local.bind(name, t); + const call = fnType.call(); + if (call?.types) { + for (const [name, t] of Object.entries(call.types)) local.bind(name, t); + } + return local; +} diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 142f58b..8207b6f 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -5,7 +5,7 @@ import { Value, val } from '../value'; import { BreakSignal, ContinueSignal } from '../flow-control'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { checkBindingName, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; @@ -13,6 +13,7 @@ import type { CodeOptions, SchemaOptions } from '../node'; import { indentCode } from './code'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export interface LoopParallel { concurrent?: Expr; @@ -36,14 +37,15 @@ export class LoopExpr extends Expr { super(); } - static from(json: LoopExprDef, registry: Registry): LoopExpr { + static from(json: LoopExprDef, scope: TypeScope): LoopExpr { + const r = scope.registry; const parallel = json.parallel ? { - concurrent: json.parallel.concurrent ? registry.parseExpr(json.parallel.concurrent) : undefined, - rate: json.parallel.rate ? registry.parseExpr(json.parallel.rate) : undefined, + concurrent: json.parallel.concurrent ? r.parseExpr(json.parallel.concurrent, scope) : undefined, + rate: json.parallel.rate ? r.parseExpr(json.parallel.rate, scope) : undefined, } : undefined; return new LoopExpr( - registry.parseExpr(json.over), - registry.parseExpr(json.body), + r.parseExpr(json.over, scope), + r.parseExpr(json.body, scope), json.key, json.value, parallel, @@ -193,11 +195,11 @@ export class LoopExpr extends Expr { return val(engine.registry.void(), undefined); } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return engine.registry.void(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const overT = p.at('over', () => walkValidate(engine, this.over, scope, p, ctx)); const gs = overT.get(); // Iterable: type's GetSet defines either a `loop` ExprDef @@ -252,7 +254,7 @@ export class LoopExpr extends Expr { // the iterable surface was missing (already errored above). const keyType = gs?.key ?? engine.registry.any(); const valueType = gs?.value ?? engine.registry.any(); - const child: TypeScope = new Map(scope); + const child: Locals = new Map(scope); child.set(this.keyName ?? 'key', keyType); child.set(this.valueName ?? 'value', valueType); p.at('body', () => walkValidate(engine, this.body, child, p, { ...ctx, inLoop: true })); diff --git a/packages/gin/src/exprs/native.ts b/packages/gin/src/exprs/native.ts index 29a5b7f..f5460a6 100644 --- a/packages/gin/src/exprs/native.ts +++ b/packages/gin/src/exprs/native.ts @@ -4,12 +4,13 @@ import type { NativeExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * NativeExpr — escape hatch calling a registered native impl by id. @@ -22,8 +23,8 @@ export class NativeExpr extends Expr { super(); } - static from(json: NativeExprDef, registry: Registry): NativeExpr { - return new NativeExpr(json.id, json.type ? registry.parse(json.type) : undefined) + static from(json: NativeExprDef, scope: TypeScope): NativeExpr { + return new NativeExpr(json.id, json.type ? scope.registry.parse(json.type, scope) : undefined) .withComment(json.comment); } @@ -49,11 +50,11 @@ export class NativeExpr extends Expr { return val(type, out); } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return this.type ?? engine.registry.any(); } - validateWalk(engine: Engine, _scope: TypeScope, p: Problems, _ctx: ValidateContext): Type { + validateWalk(engine: Engine, _scope: Locals, p: Problems, _ctx: ValidateContext): Type { if (!engine.registry.getNative(this.id)) { p.warn('native.unknown', `native impl '${this.id}' is not registered`); } diff --git a/packages/gin/src/exprs/new.ts b/packages/gin/src/exprs/new.ts index 35b3630..deb5aff 100644 --- a/packages/gin/src/exprs/new.ts +++ b/packages/gin/src/exprs/new.ts @@ -5,12 +5,13 @@ import { Value, val } from '../value'; import { ObjType } from '../types/obj'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * NewExpr — construct a Value of a type. @@ -37,8 +38,8 @@ export class NewExpr extends Expr { super(); } - static from(json: NewExprDef, registry: Registry): NewExpr { - return new NewExpr(registry.parse(json.type), json.value).withComment(json.comment); + static from(json: NewExprDef, scope: TypeScope): NewExpr { + return new NewExpr(scope.registry.parse(json.type, scope), json.value).withComment(json.comment); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -131,11 +132,11 @@ export class NewExpr extends Expr { return val(type, type.create()); } - typeOf(_engine: Engine, _scope: TypeScope): Type { + typeOf(_engine: Engine, _scope: Locals): Type { return this.type; } - validateWalk(_engine: Engine, _scope: TypeScope, _p: Problems, _ctx: ValidateContext): Type { + validateWalk(_engine: Engine, _scope: Locals, _p: Problems, _ctx: ValidateContext): Type { return this.type; } diff --git a/packages/gin/src/exprs/set.ts b/packages/gin/src/exprs/set.ts index 8d8e372..c421b56 100644 --- a/packages/gin/src/exprs/set.ts +++ b/packages/gin/src/exprs/set.ts @@ -5,12 +5,13 @@ import { Value, val } from '../value'; import { Path, PropStep } from '../path'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; import { baseExprFields, pathStepSchema } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * SetExpr — assign to a Path. Returns Value: @@ -25,8 +26,8 @@ export class SetExpr extends Expr { super(); } - static from(json: SetExprDef, registry: Registry): SetExpr { - return new SetExpr(Path.from(json.path, registry), registry.parseExpr(json.value)) + static from(json: SetExprDef, scope: TypeScope): SetExpr { + return new SetExpr(Path.from(json.path, scope), scope.registry.parseExpr(json.value, scope)) .withComment(json.comment); } @@ -55,11 +56,11 @@ export class SetExpr extends Expr { return this.path.walk(scope, engine, { mode: 'set', setValue: value }); } - typeOf(engine: Engine, _scope: TypeScope): Type { + typeOf(engine: Engine, _scope: Locals): Type { return engine.registry.bool(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const valueT = p.at('value', () => this.value.validateWalk(engine, scope, p, ctx)); const targetT = this.path.validateWalk(engine, scope, p, ctx, 'set'); // The rvalue type must be assignable to the target position's type. diff --git a/packages/gin/src/exprs/switch.ts b/packages/gin/src/exprs/switch.ts index f1aab20..53585fb 100644 --- a/packages/gin/src/exprs/switch.ts +++ b/packages/gin/src/exprs/switch.ts @@ -4,7 +4,7 @@ import type { SwitchExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { typeOf, walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; @@ -13,6 +13,7 @@ import { indentCode, renderStatementBody, findEscapingFlow } from './code'; import { FlowExpr } from './flow'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; export interface SwitchCase { equals: ReadonlyArray; @@ -34,14 +35,15 @@ export class SwitchExpr extends Expr { super(); } - static from(json: SwitchExprDef, registry: Registry): SwitchExpr { + static from(json: SwitchExprDef, scope: TypeScope): SwitchExpr { + const r = scope.registry; return new SwitchExpr( - registry.parseExpr(json.value), + r.parseExpr(json.value, scope), json.cases.map((c) => ({ - equals: c.equals.map((e) => registry.parseExpr(e)), - body: registry.parseExpr(c.body), + equals: c.equals.map((e) => r.parseExpr(e, scope)), + body: r.parseExpr(c.body, scope), })), - json.else ? registry.parseExpr(json.else) : undefined, + json.else ? r.parseExpr(json.else, scope) : undefined, ).withComment(json.comment); } @@ -78,14 +80,14 @@ export class SwitchExpr extends Expr { return val(engine.registry.void(), undefined); } - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { const ts = this.cases.map((c) => typeOf(engine, c.body, scope)); if (this.otherwise) ts.push(typeOf(engine, this.otherwise, scope)); if (ts.length === 0) return engine.registry.void(); return ts.length === 1 ? ts[0]! : engine.registry.or(ts); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const valueT = p.at('value', () => walkValidate(engine, this.value, scope, p, ctx)); const ts: Type[] = []; for (let i = 0; i < this.cases.length; i++) { diff --git a/packages/gin/src/exprs/template.ts b/packages/gin/src/exprs/template.ts index 21e1171..54a3dc8 100644 --- a/packages/gin/src/exprs/template.ts +++ b/packages/gin/src/exprs/template.ts @@ -4,7 +4,7 @@ import type { TemplateExprDef, NewExprDef, ExprDef } from '../schema'; import { Value, val } from '../value'; import type { Registry } from '../registry'; import type { Type } from '../type'; -import type { TypeScope } from '../analysis'; +import type { Locals } from '../analysis'; import { walkValidate } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; @@ -12,6 +12,7 @@ import type { CodeOptions, SchemaOptions } from '../node'; import { NewExpr } from './new'; import { z } from 'zod'; import { baseExprFields } from '../schemas'; +import type { TypeScope } from '../type-scope'; /** * TemplateExpr — string interpolation. @@ -24,18 +25,19 @@ export class TemplateExpr extends Expr { super(); } - static from(json: TemplateExprDef, registry: Registry): TemplateExpr { + static from(json: TemplateExprDef, scope: TypeScope): TemplateExpr { + const r = scope.registry; // template is declared `string` in schema but historically evaluated as ExprDef. const t = json.template as unknown; const templateExpr = t && typeof t === 'object' && 'kind' in (t as ExprDef) - ? registry.parseExpr(t as ExprDef) - : registry.parseExpr({ + ? r.parseExpr(t as ExprDef, scope) + : r.parseExpr({ kind: 'new', type: { name: 'text' }, value: String(t), - } as NewExprDef); - return new TemplateExpr(templateExpr, registry.parseExpr(json.params)) + } as NewExprDef, scope); + return new TemplateExpr(templateExpr, r.parseExpr(json.params, scope)) .withComment(json.comment); } @@ -67,11 +69,11 @@ export class TemplateExpr extends Expr { return val(engine.registry.text(), out); } - typeOf(_engine: Engine, _scope: TypeScope): Type { + typeOf(_engine: Engine, _scope: Locals): Type { return _engine.registry.text(); } - validateWalk(engine: Engine, scope: TypeScope, p: Problems, ctx: ValidateContext): Type { + validateWalk(engine: Engine, scope: Locals, p: Problems, ctx: ValidateContext): Type { const text = engine.registry.text(); const tmplT = p.at('template', () => walkValidate(engine, this.template, scope, p, ctx)); if (!text.compatible(tmplT)) { diff --git a/packages/gin/src/extension.ts b/packages/gin/src/extension.ts index c433b2d..e207c62 100644 --- a/packages/gin/src/extension.ts +++ b/packages/gin/src/extension.ts @@ -1,4 +1,5 @@ import type { Registry } from './registry'; +import type { TypeScope } from './type-scope'; import type { TypeDef } from './schema'; import { Value, val } from './value'; import { @@ -27,13 +28,14 @@ import type { Expr } from './expr'; * any generics the base already has). Each key is the parameter name; the * value is the current binding (use `registry.any()` as a default, or a * concrete Type for a bound instance). Placeholders elsewhere in the - * local spec use `registry.generic('T')` — those get substituted by - * `.bind({T: …})` via the standard substitute walk. + * local spec use `registry.alias('T')` — those resolve through any + * extra `TypeScope` passed at access time (e.g. a path call site's + * `` bindings) before falling back to the captured layer. * * registry.extend('object', { * name: 'Box', * generic: { T: registry.any() }, - * props: { value: { type: registry.generic('T') } }, + * props: { value: { type: registry.alias('T') } }, * }) */ export interface ExtensionLocal { @@ -118,9 +120,10 @@ export class Extension extends Type { : original; // Thread local generic declarations up to the base Type so `this.generic` - // reflects the Extension's own parameters. Binding via `.bind(bindings)` - // walks through substituteChildren, which rebuilds the Extension with - // substituted placeholders. + // reflects the Extension's own parameters. Generic specialization at + // call sites is handled by passing an extra TypeScope into the + // resolution-touching methods (parse / valid / call / props / etc.) — + // AliasType reads the override layer first, then its captured scope. super(registry, narrowedOptions, local.generic ?? {}); this.original = original; this.base = effectiveBase; @@ -131,18 +134,18 @@ export class Extension extends Type { // ─── VALUE OPERATIONS (delegate to effective base) ───────────────────── - valid(raw: unknown): raw is RuntimeOf { - return this.base.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return this.base.valid(raw, scope); } - parse(json: unknown): Value { - const v = this.base.parse(json); + parse(json: unknown, scope?: TypeScope): Value { + const v = this.base.parse(json, scope); // Re-wrap so Value.type is this Extension, not the base. return new Value(this, v.raw); } - encode(raw: RuntimeOf): JSONOf { - return this.base.encode(raw); + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { + return this.base.encode(raw, scope); } create(): RuntimeOf { @@ -155,20 +158,20 @@ export class Extension extends Type { // ─── TYPE RELATIONS ──────────────────────────────────────────────────── - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (opts?.exact) { // Exact requires same Extension name. if (other instanceof Extension && other.name === this.name) { - return this.base.compatible(other.base, opts); + return this.base.compatible(other.base, opts, scope); } return false; } // Covariant: compatible with base (looser) and with other Extensions // sharing a compatible base. if (other instanceof Extension) { - return this.base.compatible(other.base, opts); + return this.base.compatible(other.base, opts, scope); } - return this.base.compatible(other, opts); + return this.base.compatible(other, opts, scope); } // ─── ALGEBRA ─────────────────────────────────────────────────────────── @@ -212,20 +215,20 @@ export class Extension extends Type { // ─── EFFECTIVE ACCESS SPECS (merge local over base) ──────────────────── - props(): Record { - return { ...this.base.props(), ...(this.local.props ?? {}) }; + props(scope?: TypeScope): Record { + return { ...this.base.props(scope), ...(this.local.props ?? {}) }; } - get(): GetSet | undefined { - return this.local.get ?? this.base.get(); + get(scope?: TypeScope): GetSet | undefined { + return this.local.get ?? this.base.get(scope); } - call(): Call | undefined { - return this.local.call ?? this.base.call(); + call(scope?: TypeScope): Call | undefined { + return this.local.call ?? this.base.call(scope); } - init(): Init | undefined { - return this.local.init ?? this.base.init(); + init(scope?: TypeScope): Init | undefined { + return this.local.init ?? this.base.init(scope); } // ─── SCHEMA ROUND-TRIP ───────────────────────────────────────────────── diff --git a/packages/gin/src/path.ts b/packages/gin/src/path.ts index c0a2ff9..af16212 100644 --- a/packages/gin/src/path.ts +++ b/packages/gin/src/path.ts @@ -9,9 +9,10 @@ import { ThrowSignal } from './flow-control'; import type { GetSet, Type } from './type'; import { Expr } from './expr'; import type { Registry } from './registry'; -import type { TypeScope } from './analysis'; +import type { Locals } from './analysis'; import type { Problems } from './problem'; import type { ValidateContext } from './expr'; +import { LocalScope, type TypeScope } from './type-scope'; /** * Path — a sequence of steps against a starting value. The third citizen @@ -35,16 +36,17 @@ export abstract class PathStep { abstract toJSON(): PathStepDef; abstract clone(): PathStep; - static from(json: PathStepDef, registry: Registry): PathStep { + static from(json: PathStepDef, scope: TypeScope): PathStep { if ('prop' in json) return new PropStep(json.prop); + const r = scope.registry; if ('args' in json) { const args: Record = {}; for (const [k, v] of Object.entries(json.args ?? {})) { - args[k] = registry.parseExpr(v); + args[k] = r.parseExpr(v, scope); } - return new CallStep(args, json.generic, json.catch ? registry.parseExpr(json.catch) : undefined); + return new CallStep(args, json.generic, json.catch ? r.parseExpr(json.catch, scope) : undefined); } - if ('key' in json) return new IndexStep(registry.parseExpr((json as PathIndexDef).key)); + if ('key' in json) return new IndexStep(r.parseExpr((json as PathIndexDef).key, scope)); throw new Error(`PathStep.from: unknown step shape`); } } @@ -81,20 +83,29 @@ export class CallStep extends PathStep { return new CallStep(args, this.generic, this.catch_?.clone()); } - /** Apply this step's generic bindings to the given callable type. */ - bindGeneric(calledType: Type, engine: Engine): Type { - if (!this.generic || Object.keys(this.generic).length === 0) return calledType; + /** Build a TypeScope of this step's generic bindings layered on top + * of `calledType.scope`. Returns the called type's scope verbatim + * when there are no bindings. Threaded through type-resolution + * methods (`call`, `parse`, etc.) at the call site so AliasTypes + * inside the called signature resolve to the bound types without + * rebuilding the type tree. */ + callSiteScope(calledType: Type): TypeScope { + if (!this.generic || Object.keys(this.generic).length === 0) { + return calledType.scope; + } const bindings: Record = {}; + // Parse each binding TypeDef in the called type's own scope so + // intra-binding name lookups (e.g. R: list) resolve naturally. for (const [k, def] of Object.entries(this.generic)) { - bindings[k] = engine.registry.parse(def); + bindings[k] = calledType.scope.parse(def); } - return calledType.bind(bindings); + return new LocalScope(calledType.scope, bindings); } /** Evaluate all arg Exprs against `scope` and return a Value. */ async buildArgsValue(calledType: Type, scope: Scope, engine: Engine): Promise { - const effectiveType = this.bindGeneric(calledType, engine); - const callable = effectiveType.call?.(); + const callScope = this.callSiteScope(calledType); + const callable = calledType.call?.(callScope); const argsType = callable?.args ?? engine.registry.obj({}); const raw: Record = {}; for (const [name, expr] of Object.entries(this.args)) { @@ -117,8 +128,8 @@ export class IndexStep extends PathStep { export class Path { constructor(readonly steps: ReadonlyArray) {} - static from(json: PathDef, registry: Registry): Path { - return new Path(json.map((s) => PathStep.from(s, registry))); + static from(json: PathDef, scope: TypeScope): Path { + return new Path(json.map((s) => PathStep.from(s, scope))); } toJSON(): PathDef { @@ -207,16 +218,15 @@ export class Path { const next = this.steps[i + 1]; const nextIsCall = next instanceof CallStep; if (nextIsCall && prop.type.call()) { - const effectiveFnType = (next as CallStep).bindGeneric(prop.type, engine); const argsValue = await (next as CallStep).buildArgsValue(prop.type, scope, engine); if (i + 1 === this.steps.length - 1 && mode.mode === 'set') { - await prop.invokeMethodSet(current, step.prop, argsValue, mode.setValue!, scope, engine, effectiveFnType); + await prop.invokeMethodSet(current, step.prop, argsValue, mode.setValue!, scope, engine, prop.type); return okSet(); } try { - current = await prop.invokeMethod(current, step.prop, argsValue, scope, engine, effectiveFnType); + current = await prop.invokeMethod(current, step.prop, argsValue, scope, engine, prop.type); } catch (sig) { if (sig instanceof ThrowSignal && (next as CallStep).catch_) { const c = scope.child({ error: sig.error }); @@ -311,7 +321,7 @@ export class Path { // ─── STATIC ANALYSIS ───────────────────────────────────────────────────── - typeOf(engine: Engine, scope: TypeScope): Type { + typeOf(engine: Engine, scope: Locals): Type { if (this.steps.length === 0) return engine.registry.any(); let current: Type | null = null; let i = 0; @@ -325,17 +335,18 @@ export class Path { i++; continue; } - const p = current.prop(step.prop); - if (!p) return engine.registry.any(); + const propI: import('./type').Prop | undefined = current.prop(step.prop); + if (!propI) return engine.registry.any(); const next = this.steps[i + 1]; - if (next instanceof CallStep && p.type.call()) { - const effective = next.bindGeneric(p.type, engine); - current = effective.call()?.returns ?? engine.registry.any(); + if (next instanceof CallStep && propI.type.call()) { + const callScope: TypeScope = next.callSiteScope(propI.type); + const ret: Type | undefined = propI.type.call(callScope)?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); i += 2; continue; } - current = p.type; + current = propI.type; i++; continue; } @@ -347,8 +358,13 @@ export class Path { } if (step instanceof CallStep) { - const effective: Type | undefined = current ? step.bindGeneric(current, engine) : undefined; - current = effective?.call()?.returns ?? engine.registry.any(); + if (current) { + const callScope: TypeScope = step.callSiteScope(current); + const ret: Type | undefined = current.call(callScope)?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); + } else { + current = engine.registry.any(); + } i++; continue; } @@ -360,7 +376,7 @@ export class Path { validateWalk( engine: Engine, - scope: TypeScope, + scope: Locals, p: Problems, ctx: ValidateContext, mode: 'get' | 'set' = 'get', @@ -393,37 +409,39 @@ export class Path { i++; continue; } - const pp = current.prop(step.prop); - if (!pp) { + const propV: import('./type').Prop | undefined = current.prop(step.prop); + if (!propV) { p.at(['path', i], () => p.error('prop.unknown', `no prop '${step.prop}' on type '${current!.name}'`)); current = engine.registry.any(); i++; continue; } const next = this.steps[i + 1]; - if (next instanceof CallStep && pp.type.call()) { + if (next instanceof CallStep && propV.type.call()) { for (const [name, argExpr] of Object.entries(next.args)) { p.at(['path', i + 1, 'args', name], () => argExpr.validateWalk(engine, scope, p, ctx)); } if (next.catch_) { p.at(['path', i + 1, 'catch'], () => next.catch_!.validateWalk(engine, scope, p, ctx)); } - const effective = next.bindGeneric(pp.type, engine); + const callScope: TypeScope = next.callSiteScope(propV.type); + const callable: import('./type').Call | undefined = propV.type.call(callScope); if (mode === 'set' && i + 1 === this.steps.length - 1) { - if (!effective.call()?.set) { + if (!callable?.set) { p.at(['path', i + 1], () => p.error('set.call.no-set', `method '${step.prop}' has no call.set`)); } } - current = effective.call()?.returns ?? engine.registry.any(); + const ret: Type | undefined = callable?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); i += 2; continue; } if (mode === 'set' && isLast) { - if (!pp.set) { + if (!propV.set) { p.at(['path', i], () => p.error('set.prop.no-set', `prop '${step.prop}' has no set expression`)); } } - current = pp.type; + current = propV.type; i++; continue; } @@ -447,13 +465,19 @@ export class Path { for (const [name, argExpr] of Object.entries(step.args)) { p.at(['path', i, 'args', name], () => argExpr.validateWalk(engine, scope, p, ctx)); } - const effective: Type | undefined = current ? step.bindGeneric(current, engine) : undefined; - if (mode === 'set' && isLast) { - if (!effective?.call()?.set) { - p.at(['path', i], () => p.error('set.call.no-set', `call on type '${current?.name ?? '?'}' has no call.set`)); + if (current) { + const callScope: TypeScope = step.callSiteScope(current); + const callable: import('./type').Call | undefined = current.call(callScope); + if (mode === 'set' && isLast) { + if (!callable?.set) { + p.at(['path', i], () => p.error('set.call.no-set', `call on type '${current?.name ?? '?'}' has no call.set`)); + } } + const ret: Type | undefined = callable?.returns; + current = ret?.simplify(callScope) ?? ret ?? engine.registry.any(); + } else { + current = engine.registry.any(); } - current = effective?.call()?.returns ?? engine.registry.any(); i++; continue; } @@ -468,11 +492,11 @@ function isEmpty(v: Value): boolean { return v.raw === null || v.raw === undefined; } -// ─── legacy walkPath wrapper ──────────────────────────────────────────────── +// ─── walkPath helper ──────────────────────────────────────────────────────── /** - * Backwards-compat: accepts a raw PathDef JSON and walks it. - * Parses through Path.from for structured traversal. + * Convenience: accepts a raw PathDef JSON or an already-parsed `Path`, + * parses if needed, and walks it. */ export async function walkPath( path: PathDef | Path, diff --git a/packages/gin/src/registry.ts b/packages/gin/src/registry.ts index eba1432..0247896 100644 --- a/packages/gin/src/registry.ts +++ b/packages/gin/src/registry.ts @@ -21,13 +21,13 @@ import type { z } from 'zod'; import { AnyType } from './types/any'; import { AndType } from './types/and'; +import { AliasType } from './types/alias'; import { BoolType } from './types/bool'; import { ColorType } from './types/color'; import { DateType } from './types/date'; import { DurationType } from './types/duration'; import { EnumType } from './types/enum'; import { FnType } from './types/fn'; -import { GenericType } from './types/generic'; import { IfaceType } from './types/iface'; import { LiteralType } from './types/literal'; import { ListType } from './types/list'; @@ -39,13 +39,13 @@ import { NumType } from './types/num'; import { ObjType } from './types/obj'; import { OptionalType } from './types/optional'; import { OrType } from './types/or'; -import { RefType } from './types/ref'; import { TextType } from './types/text'; import { TimestampType } from './types/timestamp'; import { TupleType } from './types/tuple'; import { TypType } from './types/typ'; import { VoidType } from './types/void'; import type { Scope } from './scope'; +import type { TypeScope } from './type-scope'; import { Value } from './value'; import { registerBuiltinNatives } from './natives'; @@ -67,7 +67,13 @@ import { registerBuiltinNatives } from './natives'; export interface TypeClass { readonly NAME: string; readonly consumes?: readonly CustomField[]; - from(json: TypeDef, registry: Registry): Type; + /** Build a Type from its JSON. `scope` is the type-name resolution + * scope (Registry as the root, LocalScope layers above for fn + * generics / call.types aliases). Use `scope.registry` to access + * the underlying Registry for child-type construction; use + * `scope.parse` (i.e. `scope.registry.parse(child, scope)`) to + * recursively parse children with the same scope. */ + from(json: TypeDef, scope: TypeScope): Type; /** JSON-shape Zod schema for this Type's TypeDef. */ toSchema(opts: SchemaOptions): z.ZodTypeAny; /** @@ -89,8 +95,9 @@ export type CustomField = 'props' | 'get' | 'call' | 'init'; const ALL_CUSTOM_FIELDS: readonly CustomField[] = ['props', 'get', 'call', 'init']; /** Native implementation — the actual JS function that runs a native op. - * Receives the current scope plus the registry for convenient access to - * built-in types when wrapping a returned raw back into a Value. */ + * Receives the current runtime scope plus the registry for convenient + * access to built-in types when wrapping a returned raw back into a + * Value. */ export type NativeImpl = (scope: Scope, registry: Registry) => Value | unknown | Promise; /** @@ -106,12 +113,17 @@ export type NativeImpl = (scope: Scope, registry: Registry) => Value | unknown | * to each class's static `from` method, and recurses through nested types * via the same entry point. */ -export class Registry implements TypeBuilder { +export class Registry implements TypeBuilder, TypeScope { private readonly classes = new Map(); private readonly namedTypes = new Map(); private readonly natives = new Map(); private readonly exprClasses = new Map(); + // ─── SCOPE INTERFACE ───────────────────────────────────────────────────── + /** Registry IS the root scope. */ + readonly parent: undefined = undefined; + get registry(): Registry { return this; } + // ─── CLASS REGISTRATION ────────────────────────────────────────────────── /** Register a built-in Type class for JSON parse dispatch. */ @@ -126,7 +138,9 @@ export class Registry implements TypeBuilder { return this; } - /** Look up a named Type by name. Registered instances win over defaults. */ + /** Look up a Type by name. Registered named instances win; falls back + * to built-in classes (synthesized canonical instance). Returns + * undefined for unknown names. Implements `TypeScope.lookup`. */ lookup(name: string): Type | undefined { if (this.namedTypes.has(name)) return this.namedTypes.get(name); const cls = this.classes.get(name); @@ -134,6 +148,11 @@ export class Registry implements TypeBuilder { return undefined; } + /** Registry has no "local-above-root" layer. See TypeScope.localLookup. */ + localLookup(_name: string): Type | undefined { + return undefined; + } + // ─── NATIVES ───────────────────────────────────────────────────────────── setNative(id: string, impl: NativeImpl): this { @@ -176,8 +195,11 @@ export class Registry implements TypeBuilder { return Array.from(this.exprClasses.values()); } - /** Parse an ExprDef (or already-parsed Expr) into an Expr instance. */ - parseExpr(json: unknown): Expr { + /** Parse an ExprDef (or already-parsed Expr) into an Expr instance. + * Optional `scope` lets callers thread a `LocalScope` (with generic / + * call.types aliases bound) through Expr.from implementations that + * recurse into nested TypeDefs (`new`, `lambda`, `native`, `define`). */ + parseExpr(json: unknown, scope: TypeScope = this): Expr { if (json instanceof Expr) return json; if (!json || typeof json !== 'object' || !('kind' in (json as object))) { throw new Error(`registry.parseExpr: expected ExprDef with kind, got ${typeof json}`); @@ -185,7 +207,7 @@ export class Registry implements TypeBuilder { const def = json as ExprDef; const cls = this.exprClasses.get(def.kind); if (!cls) throw new Error(`registry.parseExpr: unknown expr kind '${def.kind}'`); - return cls.from(def, this); + return cls.from(def, scope); } /** @@ -204,30 +226,30 @@ export class Registry implements TypeBuilder { * of `Value.toJSON()` — decode the TypeDef via `parse`, then ask that * Type to parse the dumped value. */ - parseValue(json: unknown, expectedType?: Type): Value { + parseValue(json: unknown, expectedType?: Type, scope?: TypeScope): Value { if (json instanceof Value) { return json; } if (json && typeof json === 'object' && 'type' in json && 'value' in json) { - return this.parse(json.type).parse(json.value); + return this.parse(json.type, scope).parse(json.value, scope); } if (!expectedType) { throw new TypeError(`registry.parseValue: expected Value or JSONValue, got ${typeof json}`); } - return expectedType.parse(json); + return expectedType.parse(json, scope); } - parse(json: unknown): Type { + parse(json: unknown, scope: TypeScope = this): Type { if (!json || typeof json !== 'object') { throw new Error(`registry.parse: expected object, got ${typeof json}`); } const def = json as TypeDef; - const result = this.parseInner(def); + const result = this.parseInner(def, scope); // `satisfies` claims: verify each against the named interface. if (def.satisfies && def.satisfies.length > 0) { for (const ifaceName of def.satisfies) { - const iface = this.lookup(ifaceName); + const iface = scope.lookup(ifaceName) ?? this.lookup(ifaceName); if (!iface) { throw new Error(`registry.parse: satisfies references unknown interface '${ifaceName}'`); } @@ -240,13 +262,50 @@ export class Registry implements TypeBuilder { return result; } - private parseInner(def: TypeDef): Type { + /** True if `def` is a bare-name shape: only `name` (and optionally + * `docs`), no structural peers. Bare-name defs route through scope + * lookup → AliasType / registered named type / canonical class. */ + private isBareNameDef(def: TypeDef): boolean { + const peers: ReadonlyArray = [ + 'extends', 'satisfies', 'generic', 'options', + 'init', 'props', 'get', 'call', 'constraint', + ]; + for (const k of peers) { + if ((def as unknown as Record)[k] !== undefined) return false; + } + return true; + } + + private parseInner(def: TypeDef, scope: TypeScope): Type { // `extends` indirection: build the base from the referenced name, wrap // in Extension with local additions/narrowings. if (def.extends) { - const base = this.lookup(def.extends); + const base = scope.lookup(def.extends) ?? this.lookup(def.extends); if (!base) throw new Error(`registry.parse: extends references unknown type '${def.extends}'`); - return new Extension(this, base, this.buildLocal(def)); + return new Extension(this, base, this.buildLocal(def, scope)); + } + + // Bare-name shape: dispatch via scope chain. + if (this.isBareNameDef(def)) { + // Walk above-registry layers — if the name is bound LOCALLY in + // any LocalScope (generic placeholder, call.types alias), wrap in + // AliasType so substitute / scope resolution works correctly. + let s: TypeScope | undefined = scope; + while (s && s !== this) { + if (s.localLookup(def.name) !== undefined) { + return new AliasType(scope, { name: def.name }); + } + s = s.parent; + } + // Registered named type — return directly (preserves instanceof). + if (this.namedTypes.has(def.name)) return this.namedTypes.get(def.name)!; + // Built-in class — dispatch eagerly to canonical instance. + const cls = this.classes.get(def.name); + if (cls) return cls.from(def, scope); + // Unknown name — AliasType (lazy; supports forward-refs to types + // registered later, e.g. self-referential `r.alias('Node')` during + // construction of Node). + return new AliasType(scope, { name: def.name }); } // Previously-registered named type (Extension or programmatically defined). @@ -261,19 +320,19 @@ export class Registry implements TypeBuilder { const consumed = new Set(cls.consumes ?? []); const leftover = ALL_CUSTOM_FIELDS.filter((f) => def[f] !== undefined && !consumed.has(f)); - if (leftover.length === 0) return cls.from(def, this); + if (leftover.length === 0) return cls.from(def, scope); const stripped: TypeDef = { ...def }; for (const f of leftover) delete stripped[f]; - const base = cls.from(stripped, this); + const base = cls.from(stripped, scope); const local: ExtensionLocal = { name: def.name, docs: def.docs, - props: leftover.includes('props') && def.props ? decodeProps(def.props, this) : undefined, - get: leftover.includes('get') && def.get ? decodeGetSet(def.get, this) : undefined, - call: leftover.includes('call') && def.call ? decodeCall(def.call, this) : undefined, - init: leftover.includes('init') && def.init ? decodeInit(def.init, this) : undefined, + props: leftover.includes('props') && def.props ? decodeProps(def.props, scope) : undefined, + get: leftover.includes('get') && def.get ? decodeGetSet(def.get, scope) : undefined, + call: leftover.includes('call') && def.call ? decodeCall(def.call, scope) : undefined, + init: leftover.includes('init') && def.init ? decodeInit(def.init, scope) : undefined, }; return new Extension(this, base, local); } @@ -293,7 +352,7 @@ export class Registry implements TypeBuilder { try { if (iface.compatible(t)) out.push(t); } catch { - // lazy proxies (ref/generic) may throw during compat — skip. + // lazy proxies (alias) may throw during compat — skip. } } @@ -316,10 +375,10 @@ export class Registry implements TypeBuilder { } /** Decode all customization fields from a TypeDef into an ExtensionLocal. */ - private buildLocal(def: TypeDef): ExtensionLocal { + private buildLocal(def: TypeDef, scope: TypeScope): ExtensionLocal { const generic = def.generic ? Object.fromEntries( - Object.entries(def.generic).map(([k, v]) => [k, this.parse(v)]), + Object.entries(def.generic).map(([k, v]) => [k, this.parse(v, scope)]), ) : undefined; return { @@ -327,11 +386,11 @@ export class Registry implements TypeBuilder { docs: def.docs, options: def.options, generic, - props: def.props ? decodeProps(def.props, this) : undefined, - get: def.get ? decodeGetSet(def.get, this) : undefined, - call: def.call ? decodeCall(def.call, this) : undefined, - init: def.init ? decodeInit(def.init, this) : undefined, - constraint: def.constraint ? this.parseExpr(def.constraint) : undefined, + props: def.props ? decodeProps(def.props, scope) : undefined, + get: def.get ? decodeGetSet(def.get, scope) : undefined, + call: def.call ? decodeCall(def.call, scope) : undefined, + init: def.init ? decodeInit(def.init, scope) : undefined, + constraint: def.constraint ? this.parseExpr(def.constraint, scope) : undefined, }; } @@ -396,8 +455,15 @@ export class Registry implements TypeBuilder { }); } - ref(name: string) { return new RefType(this, { name }); } - generic(name: string) { return new GenericType(this, { name }); } + + /** Bare-name reference / generic-parameter placeholder. + * JSON form is `{name: 'X'}` — interpretation depends on scope: + * resolves to a registered named type, a built-in class instance, a + * generic placeholder bound on the enclosing fn, or a `call.types` + * alias. Call-site specialization (e.g. path-step ``) + * passes an extra `TypeScope` at access time; AliasType.resolve + * consults it before its captured scope. No type-tree rebuild. */ + alias(name: string) { return new AliasType(this, { name }); } typ(constraint: Type): TypType { return new TypType(this, constraint); @@ -517,8 +583,6 @@ export const BUILTIN_TYPES: TypeClass[] = [ LiteralType, FnType, IfaceType, - RefType, - GenericType, TypType, DateType, TimestampType, diff --git a/packages/gin/src/schemas.ts b/packages/gin/src/schemas.ts index 005301c..64a5880 100644 --- a/packages/gin/src/schemas.ts +++ b/packages/gin/src/schemas.ts @@ -99,7 +99,7 @@ export function callDefSchema(opts: SchemaOptions): z.ZodTypeAny { .describe( 'Call-local type aliases. Declare reusable named types here ONCE and reference them inside `args` / `returns` / `throws` / `get` / `set` as a bare `{name: ""}`. ' + 'Aliases process AFTER any enclosing generics (so they may reference generic placeholders) and BEFORE the call slots — the call slots resolve them at parse time. ' + - 'Sequential: later aliases may reference earlier ones; forward / self references throw. Use this whenever the same composite type appears more than once in a signature — instead of writing `num{whole:true, min:1}` four times, declare `{ counter: { name:"num", options:{whole:true,min:1} } }` once and reference `{name:"counter"}`.', + 'Sequential: later aliases may reference earlier ones. Use this whenever the same composite type appears more than once in a signature — instead of writing `num{whole:true, min:1}` four times, declare `{ counter: { name:"num", options:{whole:true,min:1} } }` once and reference `{name:"counter"}`.', ), args: opts.Type, returns: opts.Type.optional(), diff --git a/packages/gin/src/spec.ts b/packages/gin/src/spec.ts index 71a7eb2..77c3325 100644 --- a/packages/gin/src/spec.ts +++ b/packages/gin/src/spec.ts @@ -1,92 +1,7 @@ import type { Registry } from './registry'; import { Call, GetSet, Init, Prop, type PropSpec, type Type } from './type'; import type { CallDef, GetSetDef, PropDef, TypeDef } from './schema'; -import { buildAliasMap, inlineCallDef } from './exprs/inline-aliases'; - -// ─── generic substitution (TypeDef tree) ───────────────────────────────── - -/** - * Walk a TypeDef substituting generic placeholders per `bindings`. Fully - * polymorphic: parses each node into a Type and dispatches to its - * `.substitute(bindings)` method, then re-encodes. GenericType overrides - * substitute() to return its binding; every other type uses the default, - * which recurses into its common child fields (generic / props / get / - * call / init). No `name === 'generic'` check lives in this file. - * - * This helper is now a thin wrapper over Type.substitute — kept for - * backwards-compat and for the registry's parse-time use (e.g., Type.bind - * and programmatic substitution). - */ -export function substituteTypeDef( - def: TypeDef, - bindings: Record, - registry: Registry, -): TypeDef { - return registry.parse(def).substitute(bindings).toJSON(); -} - -/** - * Default child-walker used by Type.substitute: recursively substitutes - * the common child-type fields without knowing anything about the outer - * type's kind. Only invoked from Type.substitute — never from user code. - */ -export function substituteChildren( - def: TypeDef, - bindings: Record, - registry: Registry, -): TypeDef { - const next: TypeDef = { ...def }; - - if (def.generic) { - const g: Record = {}; - for (const [k, v] of Object.entries(def.generic)) { - g[k] = substituteTypeDef(v, bindings, registry); - } - next.generic = g; - } - - if (def.props) { - const p: Record = {}; - for (const [k, pd] of Object.entries(def.props)) { - p[k] = { ...pd, type: substituteTypeDef(pd.type, bindings, registry) }; - } - next.props = p; - } - - if (def.get) { - next.get = { - ...def.get, - key: substituteTypeDef(def.get.key, bindings, registry), - value: substituteTypeDef(def.get.value, bindings, registry), - }; - } - - if (def.call) { - // If the source CallDef declared `types` (call-local aliases), - // first inline them so substituteTypeDef doesn't try to - // registry.parse a bare alias-name and throw. Then drop `types` - // from the substituted output — bound calls are alias-free in - // both their parsed and JSON forms (Plan-agent footgun fix). - const callBase = def.call.types - ? inlineCallDef(def.call, buildAliasMap(def.call.types, registry)) - : def.call; - next.call = { - ...callBase, - args: substituteTypeDef(callBase.args, bindings, registry), - returns: callBase.returns ? substituteTypeDef(callBase.returns, bindings, registry) : undefined, - throws: callBase.throws ? substituteTypeDef(callBase.throws, bindings, registry) : undefined, - }; - // `types` was either absent or already consumed by the inline - // pass — either way, the substituted output should not carry it. - delete (next.call as { types?: unknown }).types; - } - - if (def.init) { - next.init = { ...def.init, args: substituteTypeDef(def.init.args, bindings, registry) }; - } - - return next; -} +import { LocalScope, type TypeScope } from './type-scope'; /** * Runtime ↔ schema conversion for Prop/GetSet/Call/Init specs. @@ -123,9 +38,9 @@ export function encodeProps(props: Record): Record, registry: Registry): Record { +export function decodeProps( + defs: Record, + scope: TypeScope, +): Record { const out: Record = {}; - for (const [name, def] of Object.entries(defs)) out[name] = decodeProp(def, registry); + for (const [name, def] of Object.entries(defs)) out[name] = decodeProp(def, scope); return out; } -export function decodeGetSet(def: GetSetDef, registry: Registry): GetSet { +export function decodeGetSet(def: GetSetDef, scope: TypeScope): GetSet { return new GetSet({ - key: registry.parse(def.key), - value: registry.parse(def.value), + key: scope.parse(def.key), + value: scope.parse(def.value), get: def.get, set: def.set, loop: def.loop, @@ -151,34 +69,44 @@ export function decodeGetSet(def: GetSetDef, registry: Registry): GetSet { }); } -export function decodeCall(def: CallDef, registry: Registry): Call { - // Build the alias source map (sequential — later may reference - // earlier). When `def.types` is undefined this is a no-op map and - // the inliner pass-through returns the slots unchanged. - const aliases = buildAliasMap(def.types, registry); - const hasAliases = Object.keys(aliases).length > 0; - const inlined = hasAliases ? inlineCallDef(def, aliases) : def; +/** + * Decode a CallDef into a `Call`. When `def.types` is non-empty, build + * a `LocalScope` layered on top of `scope` and bind each alias to its + * (sequentially-parsed) Type — earlier aliases are visible to later + * ones and to the call's args/returns/throws/get/set. The call retains + * the alias map so `Call.toJSON()` can round-trip it. + */ +export function decodeCall(def: CallDef, scope: TypeScope): Call { + let inner: TypeScope = scope; + let aliases: Record | undefined; + if (def.types && Object.keys(def.types).length > 0) { + const local = new LocalScope(scope); + inner = local; + aliases = {}; + for (const [name, aliasDef] of Object.entries(def.types)) { + const t = local.parse(aliasDef); + local.bind(name, t); + aliases[name] = t; + } + } return new Call({ - args: registry.parse(inlined.args) as Type, - returns: inlined.returns ? registry.parse(inlined.returns) : undefined, - throws: inlined.throws ? registry.parse(inlined.throws) : undefined, - get: inlined.get, - set: inlined.set, + args: inner.parse(def.args) as Type, + returns: def.returns ? inner.parse(def.returns) : undefined, + throws: def.throws ? inner.parse(def.throws) : undefined, + get: def.get, + set: def.set, docs: def.docs, - // Source-form preservation, only when aliases were actually used. - types: hasAliases ? aliases : undefined, - sourceArgs: hasAliases ? def.args : undefined, - sourceReturns: hasAliases ? def.returns : undefined, - sourceThrows: hasAliases ? def.throws : undefined, - sourceGet: hasAliases ? def.get : undefined, - sourceSet: hasAliases ? def.set : undefined, + types: aliases, }); } -export function decodeInit(def: NonNullable, registry: Registry): Init { +export function decodeInit( + def: NonNullable, + scope: TypeScope, +): Init { return new Init({ - args: registry.parse(def.args) as Type, + args: scope.parse(def.args) as Type, run: def.run, docs: def.docs, }); diff --git a/packages/gin/src/type-scope.ts b/packages/gin/src/type-scope.ts index 411ba97..a366ca7 100644 --- a/packages/gin/src/type-scope.ts +++ b/packages/gin/src/type-scope.ts @@ -1,5 +1,7 @@ import type { Registry } from './registry'; import type { Type } from './type'; +import type { Expr } from './expr'; +import type { TypeDef, ExprDef } from './schema'; /** * Type-name resolution scope. A tree of name → Type bindings rooted at @@ -7,7 +9,7 @@ import type { Type } from './type'; * and by `Registry.parse` to dispatch bare-name TypeDefs to AliasType * when X is bound in a local scope. * - * - The Registry is the root scope; it implements `Scope` directly. + * - The Registry is the root scope; it implements `TypeScope` directly. * Its `lookup` walks `namedTypes` and built-in `classes`. * - `LocalScope` wraps a parent scope with an overlay map. Used by * `decodeCall` to scope `CallDef.types` aliases, by FnType to scope @@ -15,22 +17,40 @@ import type { Type } from './type'; * * Distinct from: * - `Scope` in `scope.ts` (runtime variable bindings — Value scope). - * - `TypeScope` in `analysis.ts` (`Map` for static + * - `Locals` in `analysis.ts` (`Map` for static * variable-type analysis during validate / typeOf). */ -export interface Scope { +export interface TypeScope { /** Look up a type by name. Returns the bound Type if present in this * scope or any parent scope; undefined if not found anywhere. */ lookup(name: string): Type | undefined; - /** The root Registry — every Scope can resolve to it via the parent - * chain. Used by Type subclasses that need to construct child types - * (e.g. `this.scope.registry.num()`) without caring about whether - * they're inside a LocalScope. */ + /** Look up a type bound DIRECTLY in this scope's local layer. + * Does NOT walk parent. Used by `Registry.parseInner` to detect + * bare-name refs that must wrap as AliasType (so generic / alias + * substitution still works) rather than resolving eagerly through + * the registry. The Registry implementation returns undefined — + * registry hits aren't "local-above-root" bindings. */ + localLookup(name: string): Type | undefined; + + /** Parse a TypeDef in this scope. Convenience over + * `scope.registry.parse(def, scope)` — most type implementations' + * `from(def, scope)` recurse via `scope.parse(child)` without + * needing to thread the registry separately. */ + parse(def: unknown): Type; + + /** Parse an ExprDef in this scope. Mirrors `parse()` for the + * expression side. Used by Expr classes that recurse into nested + * expressions / lambdas / new defs. */ + parseExpr(def: unknown): Expr; + + /** The root Registry — every TypeScope can resolve to it via the + * parent chain. Use this to access builder methods (`registry.num()`) + * for fresh built-in instances. */ readonly registry: Registry; /** Parent scope, or undefined for the root (Registry). */ - readonly parent?: Scope; + readonly parent?: TypeScope; } /** @@ -39,12 +59,12 @@ export interface Scope { * builds (later aliases referencing earlier ones); the caller is * responsible for adding bindings in order if dependencies exist. */ -export class LocalScope implements Scope { - readonly parent: Scope; +export class LocalScope implements TypeScope { + readonly parent: TypeScope; readonly registry: Registry; private readonly local: Record; - constructor(parent: Scope, local: Record = {}) { + constructor(parent: TypeScope, local: Record = {}) { this.parent = parent; this.registry = parent.registry; this.local = local; @@ -54,6 +74,18 @@ export class LocalScope implements Scope { return this.local[name] ?? this.parent.lookup(name); } + localLookup(name: string): Type | undefined { + return this.local[name]; + } + + parse(def: unknown): Type { + return this.registry.parse(def as TypeDef, this); + } + + parseExpr(def: unknown): Expr { + return this.registry.parseExpr(def as ExprDef, this); + } + /** Add a binding to this scope's local map. Used by sequential * alias / generic build steps where each entry may reference * earlier ones. */ diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index fe1e783..dd77afe 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -1,8 +1,8 @@ import type { Registry } from './registry'; +import type { TypeScope } from './type-scope'; import type { ExprDef, TypeDef, PathDef, PathStepDef, PropDef, GetSetDef, CallDef } from './schema'; import type { Expr } from './expr'; import { Value, val } from './value'; -import { substituteChildren } from './spec'; import type { Node, CodeOptions } from './node'; import type { Engine } from './engine'; import { Problems } from './problem'; @@ -216,20 +216,13 @@ export class GetSet { /** * Runtime Call — callable spec, with arg/return/throws Types resolved. * - * The parsed `args` / `returns` / `throws` (and `get` / `set` ExprDefs - * that flow through to the engine) are the fully-inlined forms — the - * runtime / analysis layer never sees alias references. - * - * When the source `CallDef` declared `types` (call-local type aliases), - * the un-inlined source forms are preserved on private fields below - * so `toJSON()` can emit the compact aliased shape rather than the - * verbose inlined one. These fields are populated only when aliases - * were actually used; otherwise undefined. - * - * On `.bind()` substitution, gin's substitute pipeline drops the - * `types` and source fields so post-bind `toJSON()` doesn't emit a - * stale source map alongside an updated parsed call. The bound Call - * is therefore alias-free in both representations. + * `args` / `returns` / `throws` are parsed inside the call's local + * scope (a `LocalScope` carrying any `CallDef.types` aliases plus + * declared generics). Bare alias references inside those Types are + * `AliasType` instances that resolve via that scope; their `toJSON()` + * emits the bare-name form, which decodeCall then rebuilds against a + * freshly constructed LocalScope on round-trip. No source-form + * preservation needed — the structure is symmetric. */ export class Call { readonly args: Type; @@ -239,19 +232,10 @@ export class Call { readonly set?: ExprDef; readonly docs?: string; - /** Call-local type aliases declared on `CallDef.types`. Public so - * rendering (toCode / toCodeDefinition) can surface the alias - * header. Populated only when aliases were used; undefined - * otherwise. */ - readonly types?: Record; - // Un-inlined source forms for round-trip via `toJSON()`. Private — - // pure bookkeeping, no external consumer. Populated alongside - // `types`; undefined when no aliases were declared. - private readonly sourceArgs?: TypeDef; - private readonly sourceReturns?: TypeDef; - private readonly sourceThrows?: TypeDef; - private readonly sourceGet?: ExprDef; - private readonly sourceSet?: ExprDef; + /** Call-local type aliases declared on `CallDef.types`, parsed. + * Public so rendering (toCode / toCodeDefinition) can surface the + * alias header. Populated only when aliases were declared. */ + readonly types?: Record; constructor(spec: { args: Type; @@ -260,12 +244,7 @@ export class Call { get?: ExprDef; set?: ExprDef; docs?: string; - types?: Record; - sourceArgs?: TypeDef; - sourceReturns?: TypeDef; - sourceThrows?: TypeDef; - sourceGet?: ExprDef; - sourceSet?: ExprDef; + types?: Record; }) { this.args = spec.args; this.returns = spec.returns; @@ -274,30 +253,18 @@ export class Call { this.set = spec.set; this.docs = spec.docs; this.types = spec.types; - this.sourceArgs = spec.sourceArgs; - this.sourceReturns = spec.sourceReturns; - this.sourceThrows = spec.sourceThrows; - this.sourceGet = spec.sourceGet; - this.sourceSet = spec.sourceSet; } /** Serialize to CallDef JSON. Inverse of `decodeCall` in spec.ts. */ toJSON(): CallDef { - if (this.types) { - // Source-form preservation: emit the un-inlined slots so the - // saved CallDef stays compact (alias names intact). - return { - docs: this.docs, - types: this.types, - args: this.sourceArgs ?? this.args.toJSON(), - returns: this.sourceReturns ?? this.returns?.toJSON(), - throws: this.sourceThrows ?? this.throws?.toJSON(), - get: this.sourceGet ?? this.get, - set: this.sourceSet ?? this.set, - }; - } + const types = this.types && Object.keys(this.types).length > 0 + ? Object.fromEntries( + Object.entries(this.types).map(([k, t]) => [k, t.toJSON()]), + ) + : undefined; return { docs: this.docs, + types, args: this.args.toJSON(), returns: this.returns?.toJSON(), throws: this.throws?.toJSON(), @@ -372,7 +339,14 @@ export type Rnd = (min: number, max: number, whole: boolean) => number; */ export abstract class Type implements Node { constructor( - readonly registry: Registry, + /** + * Type-name resolution scope. Usually the Registry (root scope); + * Types parsed inside an FnType's generic-parameter scope or a + * `CallDef.types` alias scope hold a `LocalScope` instead, so that + * any `AliasType` captured in their tree can resolve through the + * same chain at use time. + */ + readonly scope: TypeScope, readonly options: O, /** * Generic parameter bindings (e.g. list stores V here). Empty for @@ -382,6 +356,10 @@ export abstract class Type implements Node { readonly generic: Record = {}, ) {} + /** Underlying Registry — shortcut for `this.scope.registry`, used + * by subclasses for builder access (`this.registry.num()` etc.). */ + get registry(): Registry { return this.scope.registry; } + /** Identifier of this type (e.g. 'num', 'text', 'list'). */ abstract readonly name: string; @@ -404,14 +382,20 @@ export abstract class Type implements Node { * solving `T` backwards through the refinement. Narrowing still works * — callers that need `Value.raw` typed simply rely on the Value's * constructor contract. + * + * Optional `scope` overlays an extra TypeScope on top of any + * AliasType resolutions inside this type — used by call-site + * generics (path step `generic: {R: numDef}`) so AliasType('R') + * resolves to num without rebuilding the type tree. */ - abstract valid(raw: unknown): boolean; + abstract valid(raw: unknown, scope?: TypeScope): boolean; /** * Parse a JSON-shape input into a Value of this type. * Throws if the input cannot be coerced to a valid raw value. + * `scope` propagates the call-site TypeScope (see `valid`). */ - abstract parse(json: unknown): Value; + abstract parse(json: unknown, scope?: TypeScope): Value; /** * Serialize a runtime raw value to its JSON shape. @@ -423,8 +407,9 @@ export abstract class Type implements Node { * Called by `Value.toJSON()` to build the outer `{type, value}` wire * envelope. For logical primitive output (no type info) callers can * walk `.raw` and read the underlying Value contents directly. + * `scope` propagates the call-site TypeScope (see `valid`). */ - abstract encode(raw: RuntimeOf): JSONOf; + abstract encode(raw: RuntimeOf, scope?: TypeScope): JSONOf; /** Default / zero raw value — used by { kind: 'new' } with no args. */ abstract create(): RuntimeOf; @@ -438,17 +423,18 @@ export abstract class Type implements Node { * Structural + (optional) strict compatibility check. * Concrete types implement this — the default impls below (accepts, * exact) compose it with pre-set option flags. + * `scope` propagates the call-site TypeScope (see `valid`). */ - abstract compatible(other: Type, opts?: CompatOptions): boolean; + abstract compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean; /** Strict: another instance of the same class must match structurally. */ - accepts(other: Type): boolean { - return this.compatible(other, { strict: true }); + accepts(other: Type, scope?: TypeScope): boolean { + return this.compatible(other, { strict: true }, scope); } /** Strict + exact: no wrapper unwrapping, no value-mode. */ - exact(other: Type): boolean { - return this.compatible(other, { strict: true, exact: true }); + exact(other: Type, scope?: TypeScope): boolean { + return this.compatible(other, { strict: true, exact: true }, scope); } /** True if this type accepts instances of other classes structurally. */ @@ -465,8 +451,9 @@ export abstract class Type implements Node { * `list`, pulling in every registry type compatible * with X. When `other` is a different class, the default fallback returns * `this` unchanged. + * `scope` propagates the call-site TypeScope (see `valid`). */ - like(_other: Type): Type { + like(_other: Type, _scope?: TypeScope): Type { return this; } @@ -492,8 +479,9 @@ export abstract class Type implements Node { */ abstract or(other: Type): Type; - /** Canonical form — collapse trivial wrappers. */ - simplify(): Type { + /** Canonical form — collapse trivial wrappers. AliasType uses + * `scope` to consult call-site bindings before its captured scope. */ + simplify(_scope?: TypeScope): Type { return this; } @@ -532,8 +520,9 @@ export abstract class Type implements Node { * The base defines universal props that every type inherits — `toAny` * is always available. Subclasses spread `super.props()` into their * return to pick these up. + * `scope` propagates the call-site TypeScope (see `valid`). */ - props(): Record { + props(_scope?: TypeScope): Record { return { toAny: this.registry.method({}, this.registry.any(), 'type.toAny'), }; @@ -556,23 +545,23 @@ export abstract class Type implements Node { } /** Effective GetSet — present iff this type supports [key] access. */ - get(): GetSet | undefined { + get(_scope?: TypeScope): GetSet | undefined { return undefined; } /** Effective Call — present iff this type is invocable. */ - call(): Call | undefined { + call(_scope?: TypeScope): Call | undefined { return undefined; } /** Effective Init — present iff this type has a custom constructor. */ - init(): Init | undefined { + init(_scope?: TypeScope): Init | undefined { return undefined; } /** Convenience over props() — single-name lookup, normalized to Prop. */ - prop(name: string): Prop | undefined { - const raw = this.props()[name]; + prop(name: string, scope?: TypeScope): Prop | undefined { + const raw = this.props(scope)[name]; return raw ? Prop.from(raw) : undefined; } @@ -582,51 +571,41 @@ export abstract class Type implements Node { * Resolve a single PathStep against this type, returning the sub-type * reached by that step (or undefined if the step doesn't apply here). * Concrete types with positional semantics (Tuple) may override. + * `scope` propagates the call-site TypeScope (see `valid`). */ - follow(step: PathStepDef): Type | undefined { + follow(step: PathStepDef, scope?: TypeScope): Type | undefined { if ('prop' in step) { - return this.prop(step.prop)?.type; + return this.prop(step.prop, scope)?.type; } if ('args' in step) { - return this.call()?.returns; + return this.call(scope)?.returns; } if ('key' in step) { - return this.get()?.value; + return this.get(scope)?.value; } return undefined; } /** Fold follow() over a whole Path. */ - at(path: PathDef): Type | undefined { + at(path: PathDef, scope?: TypeScope): Type | undefined { let current: Type | undefined = this; for (const step of path) { if (!current) return undefined; - current = current.follow(step); + current = current.follow(step, scope); } return current; } - // ─── GENERIC BINDING ───────────────────────────────────────────────────── - - /** - * Substitute generic placeholders in this type using the given bindings. - * Delegates to `substitute(bindings)` — each Type class chooses its own - * substitution semantics polymorphically. - */ - bind(bindings: Record): Type { - if (Object.keys(bindings).length === 0) return this; - return this.substitute(bindings); - } - - /** - * Default substitution: walk the common child-type fields via the - * JSON-shape helper. GenericType overrides to return its binding. - * Other types with no generic placeholders just return this. - */ - substitute(bindings: Record): Type { - if (Object.keys(bindings).length === 0) return this; - return this.registry.parse(substituteChildren(this.toJSON(), bindings, this.registry)); - } + // ─── GENERIC RESOLUTION (scope-based; no bind/substitute) ─────────────── + // + // Generic placeholders are AliasType instances whose `scope` chain + // includes the binding (see `FnType.from`'s LocalScope, decodeCall's + // alias map, etc.). To specialize a generic at a call site, callers + // pass an extra `scope: TypeScope` (a LocalScope layered on top of + // the captured scope, with call-site bindings) into the methods + // that resolve types — `parse`, `valid`, `compatible`, `props`, + // `call`, etc. AliasType.resolve consults `extra` first, falling + // back to its captured scope. No type tree is rebuilt. // ─── SCHEMA ROUND-TRIP ─────────────────────────────────────────────────── @@ -806,12 +785,8 @@ export abstract class Type implements Node { // reading the constructor / call signature lines below. const call = this.definitionCall(); if (call?.types) { - for (const [name, def] of Object.entries(call.types)) { - try { - lines.push(` type ${name} = ${this.registry.parse(def).toCode()};`); - } catch { - lines.push(` type ${name} = ${JSON.stringify(def)};`); - } + for (const [name, t] of Object.entries(call.types)) { + lines.push(` type ${name} = ${t.toCode()};`); } } @@ -951,35 +926,30 @@ export function optionsCode(opts: object | undefined | null): string { /** * Render a Call's `types` (call-local type aliases) as a header block * `{a: ; b: }` immediately after the generic params and - * before the parameter list. Each alias is parsed in isolation so its - * generic-placeholder references render as `T` etc. Empty / missing - * map → empty string. + * before the parameter list. Empty / missing map → empty string. */ export function renderCallTypes( - registry: { parse(def: TypeDef): Type }, - types: Record | undefined, + types: Record | undefined, ): string { if (!types) return ''; const keys = Object.keys(types); if (keys.length === 0) return ''; - const parts = keys.map((k) => { - try { return `${k}: ${registry.parse(types[k]!).toCode()}`; } - catch { return `${k}: ${JSON.stringify(types[k])}`; } - }); + const parts = keys.map((k) => `${k}: ${types[k]!.toCode()}`); return `{${parts.join('; ')}}`; } /** - * Render a type's generic-parameter map as ``. `T` when bound - * is `any` (unconstrained) or a self-referencing GenericType placeholder, - * `T: code` otherwise. Shared by type headers and fn signatures. + * Render a type's generic-parameter map as ``. `T` when + * bound is `any` (unconstrained) or a self-referencing AliasType + * placeholder, `T: code` otherwise. Shared by type headers and fn + * signatures. */ export function renderGenerics(generic: Record): string { const keys = Object.keys(generic); if (keys.length === 0) return ''; const parts = keys.map((k) => { const t = generic[k]!; - const selfRef = t.name === 'generic' + const selfRef = t.name === 'alias' && (t.options as { name?: string } | undefined)?.name === k; return t.name === 'any' || selfRef ? k : `${k}: ${t.toCode()}`; }); diff --git a/packages/gin/src/types/alias.ts b/packages/gin/src/types/alias.ts new file mode 100644 index 0000000..35cfa4e --- /dev/null +++ b/packages/gin/src/types/alias.ts @@ -0,0 +1,206 @@ +import type { PathStepDef, TypeDef } from '../schema'; +import { Value } from '../value'; +import { + type Call, + type CompatOptions, + type GetSet, + type Init, + type Prop, + type PropSpec, + type Rnd, + Type, +} from '../type'; +import type { TypeScope } from '../type-scope'; +import { z } from 'zod'; +import type { SchemaOptions, ValueSchemaOptions } from '../node'; + + +export interface AliasOptions { + name: string; +} + +/** + * AliasType — a bare-name reference. Covers two roles via a single + * runtime class: a lazy reference to a registered named type, and an + * unbound type-parameter placeholder. Whichever role applies is + * scope-driven, never structural. + * + * JSON shape: `{name: 'X'}` (bare — no peers like `options`, + * `generic`, `props`, etc.; those would route to a class instead). + * + * Resolution: `this.resolve(extra?)` walks an optional caller- + * supplied `extra` scope first, then falls back to `this.scope`. The + * caller passes `extra` to override the captured scope at access + * time — this is how call-site generic bindings (e.g. `` on + * a path step) reach the AliasTypes inside a fn's signature without + * rebuilding the type tree. Every value/access method takes an + * optional `scope` argument that propagates through children. + * - Hit on `extra` → caller's local layer (call-site bindings). + * - Hit on `this.scope` (LocalScope chain → Registry) → captured + * layer (generic placeholder bound by the enclosing fn, alias + * declared in `CallDef.types`, registered named type, built-in + * class instance). + * - Miss → AliasType behaves as an unbound placeholder (compatible + * with everything, validates anything, no props). + */ +export class AliasType extends Type { + static readonly NAME = 'alias'; + readonly name = AliasType.NAME; + + constructor(scope: TypeScope, options: AliasOptions) { + super(scope, options); + } + + static from(json: TypeDef, scope: TypeScope): AliasType { + return new AliasType(scope, { name: json.name }); + } + + static toSchema(_opts: SchemaOptions): z.ZodTypeAny { + // AliasType isn't a normal Type-union branch — its JSON shape + // `{name: ''}` collides with every named class. Schema + // consumers (LLM type union) don't surface AliasType directly; + // bare names route through the registered class / named-type + // branches at parse time. This stub exists for completeness. + return z.object({ name: z.string() }).passthrough(); + } + + static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { + return z.any(); + } + + /** Resolve via `extra` (caller-supplied call-site scope) first, then + * the captured `this.scope`. Returns undefined when unresolved (so + * callers can fall back to placeholder behavior). */ + private resolve(extra?: TypeScope): Type | undefined { + if (extra) { + const t = extra.lookup(this.options.name); + if (t) return t; + } + return this.scope.lookup(this.options.name); + } + + // ─── delegating ops ───────────────────────────────────────────────────── + // When resolved, every value-side op delegates to the target — and + // forwards `scope` so AliasTypes nested inside the resolved target + // also see the call-site bindings. + // When unresolved, behave as a permissive placeholder: + // valid/compatible accept anything, props is empty, etc. + + valid(raw: unknown, scope?: TypeScope): boolean { + const t = this.resolve(scope); + return t ? t.valid(raw, scope) : true; + } + + parse(json: unknown, scope?: TypeScope): Value { + const t = this.resolve(scope); + if (!t) return new Value(this, json); + const v = t.parse(json, scope); + return new Value(this, v.raw); + } + + encode(raw: any, scope?: TypeScope): any { + const t = this.resolve(scope); + return t ? t.encode(raw, scope) : raw; + } + + create(): any { + const t = this.resolve(); + return t ? t.create() : null; + } + + random(rnd: Rnd): any { + const t = this.resolve(); + return t ? t.random(rnd) : null; + } + + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { + const t = this.resolve(scope); + return t ? t.compatible(other, opts, scope) : true; + } + + flexible(): boolean { return true; } + + /** Unbound aliases are universal placeholders; resolved aliases + * inherit the target's classification (most concrete types are not + * universal, so this defaults to false once resolved). */ + isUniversal(): boolean { + const t = this.resolve(); + return t ? t.isUniversal() : true; + } + + or(other: Type): Type { + const t = this.resolve(); + return t ? t.or(other) : this; + } + + /** Collapse to the resolved target — used by callers that prefer a + * concrete type over a lazy alias when both exist. */ + simplify(scope?: TypeScope): Type { + return this.resolve(scope) ?? this; + } + + narrow(local: Partial): AliasOptions { + return { name: local.name ?? this.options.name }; + } + + props(scope?: TypeScope): Record { + const t = this.resolve(scope); + return t ? t.props(scope) : super.props(scope); + } + + get(scope?: TypeScope): GetSet | undefined { + return this.resolve(scope)?.get(scope); + } + + call(scope?: TypeScope): Call | undefined { + return this.resolve(scope)?.call(scope); + } + + init(scope?: TypeScope): Init | undefined { + return this.resolve(scope)?.init(scope); + } + + follow(step: PathStepDef, scope?: TypeScope): Type | undefined { + return this.resolve(scope)?.follow(step, scope); + } + + /** Bare-name JSON shape. Unconditional — `{name: this.options.name}`, + * no `options` wrapper. Round-trip relies on the parse-side scope to + * rebuild the AliasType. */ + toJSON(): TypeDef { + return { name: this.options.name }; + } + + clone(): AliasType { + return new AliasType(this.scope, { ...this.options }); + } + + toCode(): string { + return this.docsPrefix() + this.options.name; + } + + toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { + // Lazy so recursive named types (Node → list) don't blow the stack. + return this.describeType(z.lazy(() => { + const t = this.resolve(); + return t ? t.toValueSchema(opts) : z.any(); + }), opts); + } + + toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + return this.describeType(z.lazy(() => { + const t = this.resolve(); + return t ? t.toNewSchema(opts) : z.any(); + }), opts, 'NewValue_'); + } + + /** An alias IS a name — instance schema is just `{name: }`. + * Lazy so self-referential named types (Node → list) don't + * infinite-recurse. */ + toInstanceSchema(): z.ZodTypeAny { + return z.lazy(() => { + const t = this.resolve(); + return t ? t.toInstanceSchema() : z.object({ name: z.literal(this.options.name) }); + }); + } +} diff --git a/packages/gin/src/types/and.ts b/packages/gin/src/types/and.ts index 7862a05..613d67f 100644 --- a/packages/gin/src/types/and.ts +++ b/packages/gin/src/types/and.ts @@ -1,3 +1,4 @@ +import type { TypeScope } from '../type-scope'; import type { Registry } from '../registry'; import type { PropDef, TypeDef } from '../schema'; import { Value } from '../value'; @@ -26,9 +27,10 @@ export class AndType extends Type { static readonly NAME = 'and'; readonly name = AndType.NAME; - static from(json: TypeDef, registry: Registry): AndType { - const parts = ((json.options?.types ?? []) as TypeDef[]).map((t) => registry.parse(t)); - return new AndType(registry, parts); + static from(json: TypeDef, scope: TypeScope): AndType { + const registry = scope.registry; + const parts = ((json.options?.types ?? []) as TypeDef[]).map((t) => scope.parse(t)); + return new AndType(scope, parts); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,22 +44,22 @@ export class AndType extends Type { return opts.Expr; } - constructor(registry: Registry, parts: Type[]) { - super(registry, { parts }); + constructor(scope: TypeScope, parts: Type[]) { + super(scope, { parts }); } get parts(): Type[] { return this.options.parts; } - valid(raw: unknown): raw is any { - return this.parts.every((p) => p.valid(raw)); + valid(raw: unknown, scope?: TypeScope): raw is any { + return this.parts.every((p) => p.valid(raw, scope)); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { // Every part must accept the raw value. for (const p of this.parts) { - if (!p.valid(json)) { + if (!p.valid(json, scope)) { throw new TypeError({ path: [], code: 'and.constraint', message: `and: value fails part ${p.name}`, severity: 'error', @@ -67,9 +69,9 @@ export class AndType extends Type { return new Value(this, json); } - encode(raw: any): any { + encode(raw: any, scope?: TypeScope): any { // Take any part's dump (they should all agree on valid values). - return this.parts[0]?.encode(raw) ?? raw; + return this.parts[0]?.encode(raw, scope) ?? raw; } create(): any { @@ -92,9 +94,9 @@ export class AndType extends Type { return this.registry.and(narrowed); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { // other assignable to And iff assignable to every part. - return this.parts.every((p) => p.compatible(other, opts)); + return this.parts.every((p) => p.compatible(other, opts, scope)); } /** Empty And vacuously matches anything — too broad for Registry.compatible. */ diff --git a/packages/gin/src/types/any.ts b/packages/gin/src/types/any.ts index 1658d63..96743cf 100644 --- a/packages/gin/src/types/any.ts +++ b/packages/gin/src/types/any.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; @@ -15,8 +15,9 @@ export class AnyType extends Type> { static readonly NAME = 'any'; readonly name = AnyType.NAME; - static from(_json: TypeDef, registry: Registry): AnyType { - return new AnyType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): AnyType { + const registry = scope.registry; + return new AnyType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { @@ -82,7 +83,7 @@ export class AnyType extends Type> { is: r.method({}, r.bool(), 'any.is', { generic: { T: r.any() } }), // Cast to target type T. Returns optional — null when the value // doesn't satisfy T. - as: r.method({}, r.optional(r.generic('T')), 'any.as', { generic: { T: r.any() } }), + as: r.method({}, r.optional(r.alias('T')), 'any.as', { generic: { T: r.any() } }), toText: r.method({}, r.text(), 'any.toText'), toBool: r.method({}, r.bool(), 'any.toBool'), eq: r.method({ other: r.any() }, r.bool(), 'any.eq'), diff --git a/packages/gin/src/types/bool.ts b/packages/gin/src/types/bool.ts index 3431418..3e960f0 100644 --- a/packages/gin/src/types/bool.ts +++ b/packages/gin/src/types/bool.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -15,8 +15,9 @@ export class BoolType extends Type { static readonly NAME = 'bool'; readonly name = BoolType.NAME; - static from(json: TypeDef, registry: Registry): BoolType { - return new BoolType(registry, (json.options ?? {}) as BoolOptions); + static from(json: TypeDef, scope: TypeScope): BoolType { + const registry = scope.registry; + return new BoolType(scope, (json.options ?? {}) as BoolOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/color.ts b/packages/gin/src/types/color.ts index a8faf9b..0f2ce48 100644 --- a/packages/gin/src/types/color.ts +++ b/packages/gin/src/types/color.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, Init, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -17,8 +17,9 @@ export class ColorType extends Type { static readonly NAME = 'color'; readonly name = ColorType.NAME; - static from(json: TypeDef, registry: Registry): ColorType { - return new ColorType(registry, (json.options ?? {}) as ColorOptions); + static from(json: TypeDef, scope: TypeScope): ColorType { + const registry = scope.registry; + return new ColorType(scope, (json.options ?? {}) as ColorOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/date.ts b/packages/gin/src/types/date.ts index 2069d8f..b5e54c0 100644 --- a/packages/gin/src/types/date.ts +++ b/packages/gin/src/types/date.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -16,8 +16,9 @@ export class DateType extends Type { static readonly NAME = 'date'; readonly name = DateType.NAME; - static from(json: TypeDef, registry: Registry): DateType { - return new DateType(registry, (json.options ?? {}) as DateOptions); + static from(json: TypeDef, scope: TypeScope): DateType { + const registry = scope.registry; + return new DateType(scope, (json.options ?? {}) as DateOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/duration.ts b/packages/gin/src/types/duration.ts index 8d1ce00..7657bab 100644 --- a/packages/gin/src/types/duration.ts +++ b/packages/gin/src/types/duration.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, Init, type Prop, type Rnd, Type } from '../type'; @@ -16,8 +16,9 @@ export class DurationType extends Type> { static readonly NAME = 'duration'; readonly name = DurationType.NAME; - static from(_json: TypeDef, registry: Registry): DurationType { - return new DurationType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): DurationType { + const registry = scope.registry; + return new DurationType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/enum.ts b/packages/gin/src/types/enum.ts index 19d0c1e..7c2d98c 100644 --- a/packages/gin/src/types/enum.ts +++ b/packages/gin/src/types/enum.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -21,10 +21,11 @@ export class EnumType extends Type> { static readonly NAME = 'enum'; readonly name = EnumType.NAME; - static from(json: TypeDef, registry: Registry): EnumType { - const V = json.generic?.V ? registry.parse(json.generic.V) : registry.text(); + static from(json: TypeDef, scope: TypeScope): EnumType { + const registry = scope.registry; + const V = json.generic?.V ? scope.parse(json.generic.V) : registry.text(); const values = (json.options?.values ?? {}) as Record; - return new EnumType(registry, V, { values }); + return new EnumType(scope, V, { values }); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -40,22 +41,22 @@ export class EnumType extends Type> { return z.union([z.string(), z.number()]); } - constructor(registry: Registry, value: Type, options: EnumOptions) { - super(registry, options, { V: value }); + constructor(scope: TypeScope, value: Type, options: EnumOptions) { + super(scope, options, { V: value }); } get value(): Type { return this.generic.V as Type; } - valid(raw: unknown): raw is RuntimeOf { - if (!this.value.valid(raw)) return false; + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + if (!this.value.valid(raw, scope)) return false; return Object.values(this.options.values).some((v) => v === raw); } - parse(json: unknown): Value { - const inner = this.value.parse(json); - if (!this.valid(inner.raw)) { + parse(json: unknown, scope?: TypeScope): Value { + const inner = this.value.parse(json, scope); + if (!this.valid(inner.raw, scope)) { throw new TypeError({ path: [], code: 'enum.not-a-member', message: `enum.parse: ${String(inner.raw)} is not one of ${Object.values(this.options.values).join(', ')}`, @@ -65,8 +66,8 @@ export class EnumType extends Type> { return new Value(this, inner.raw); } - encode(raw: RuntimeOf): JSONOf { - return this.value.encode(raw); + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { + return this.value.encode(raw, scope); } create(): RuntimeOf { @@ -90,9 +91,9 @@ export class EnumType extends Type> { ); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof EnumType)) return false; - if (!this.value.compatible(other.value, opts)) return false; + if (!this.value.compatible(other.value, opts, scope)) return false; if (!opts?.value) return true; // value-mode: other's values must be a subset of ours return Object.values(other.options.values).every((v) => diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 0dba7ca..225ff1e 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -1,8 +1,8 @@ -import type { Registry } from '../registry'; import type { ExprDef, TypeDef } from '../schema'; import { Value } from '../value'; import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderCallTypes, renderGenerics } from '../type'; import { decodeCall } from '../spec'; +import { LocalScope, type TypeScope } from '../type-scope'; import { z } from 'zod'; import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema } from '../schemas'; @@ -24,18 +24,28 @@ export class FnType extends Type> { readonly _call: Call; - static from(json: TypeDef, registry: Registry): FnType { + static from(json: TypeDef, scope: TypeScope): FnType { + const registry = scope.registry; + // Generics declared on the fn — bind each into a LocalScope so that + // bare `{name: 'T'}` inside the call signature resolves to the + // generic placeholder via AliasType (and supports later + // substitution via .bind). const generic: Record = {}; + const local = new LocalScope(scope); if (json.generic) { - for (const [k, def] of Object.entries(json.generic)) generic[k] = registry.parse(def); + for (const [k, def] of Object.entries(json.generic)) { + const t = local.parse(def); + generic[k] = t; + local.bind(k, t); + } } if (!json.call) { - return new FnType(registry, new Call({ + return new FnType(local, new Call({ args: registry.any() as Type, returns: registry.any(), }), generic); } - return new FnType(registry, decodeCall(json.call, registry), generic); + return new FnType(local, decodeCall(json.call, local), generic); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -53,22 +63,22 @@ export class FnType extends Type> { } constructor( - registry: Registry, + scope: TypeScope, call: Call | ConstructorParameters[0], generic: Record = {}, ) { - super(registry, {}, generic); + super(scope, {}, generic); this._call = call instanceof Call ? call : new Call(call); } - valid(raw: unknown): boolean { + valid(raw: unknown, _scope?: TypeScope): boolean { if (typeof raw === 'function') return true; if (typeof raw === 'string') return true; if (raw && typeof raw === 'object' && 'kind' in (raw as Record)) return true; return false; } - parse(json: unknown): Value { + parse(json: unknown, _scope?: TypeScope): Value { // Functions aren't JSON-serializable; accept either a string ref or an // ExprDef (e.g. { kind: 'lambda' }). Native JS functions can only come // from in-process construction, not JSON parse. @@ -80,7 +90,7 @@ export class FnType extends Type> { return new Value(this, null as any); } - encode(raw: ((...args: any[]) => any) | ExprDef | string): any { + encode(raw: ((...args: any[]) => any) | ExprDef | string, _scope?: TypeScope): any { if (typeof raw === 'string') return raw; if (typeof raw === 'function') return null; // native, not serializable return raw; @@ -106,13 +116,13 @@ export class FnType extends Type> { return r.fn(args as Type, returns, throws, this.generic); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof FnType)) return false; // args: contravariant — this.args must accept other.args - if (!this._call.args.compatible(other._call.args, opts)) return false; + if (!this._call.args.compatible(other._call.args, opts, scope)) return false; // returns: covariant — other.returns must be compatible with this.returns if (this._call.returns && other._call.returns) { - if (!this._call.returns.compatible(other._call.returns, opts)) return false; + if (!this._call.returns.compatible(other._call.returns, opts, scope)) return false; } return true; } @@ -134,12 +144,12 @@ export class FnType extends Type> { return {}; } - call(): Call { + call(_scope?: TypeScope): Call { return this._call; } - props(): Record { - return super.props() as Record; + props(scope?: TypeScope): Record { + return super.props(scope) as Record; } toJSON(): TypeDef { @@ -173,7 +183,7 @@ export class FnType extends Type> { toCode(): string { const ret = this._call.returns?.toCode() ?? 'void'; return this.docsPrefix() - + `${renderGenerics(this.generic)}${renderCallTypes(this.registry, this._call.types)}(${formatParams(this._call.args)}): ${ret}`; + + `${renderGenerics(this.generic)}${renderCallTypes(this._call.types)}(${formatParams(this._call.args)}): ${ret}`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/generic.ts b/packages/gin/src/types/generic.ts deleted file mode 100644 index 1589d53..0000000 --- a/packages/gin/src/types/generic.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Registry } from '../registry'; -import type { TypeDef } from '../schema'; -import { Value } from '../value'; -import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; -import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; - - -export interface GenericOptions { - name: string; -} - -/** - * GenericType — a type-parameter placeholder (e.g. `V`, `R`). It - * carries no structure itself; its meaning is determined by the - * bindings in scope. `bind(bindings)` on an enclosing type substitutes - * this placeholder with the bound Type. - * - * Before binding, Generic is maximally permissive — validation passes - * any value, compatibility is true, props is empty. This mirrors how - * TypeScript treats unconstrained type parameters inside a generic body. - */ -export class GenericType extends Type { - static readonly NAME = 'generic'; - readonly name = GenericType.NAME; - - static from(json: TypeDef, registry: Registry): GenericType { - const name = (json.options?.name ?? 'T') as string; - return new GenericType(registry, { name }); - } - - static toSchema(opts: SchemaOptions): z.ZodTypeAny { - return z.object({ - name: z.literal('generic'), - options: z.object({ name: z.string() }), - }).meta({ aid: 'Type_generic' }); - } - - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.any(); } - - valid(_raw: unknown): _raw is any { - return true; - } - - parse(json: unknown): Value { - return new Value(this, json); - } - - encode(raw: any): any { - return raw; - } - - create(): any { - return null; - } - - random(_rnd: Rnd): any { - return null; - } - - compatible(_other: Type, _opts?: CompatOptions): boolean { - return true; - } - - flexible(): boolean { - return true; - } - - isUniversal(): boolean { - return true; - } - - or(_other: Type): Type { - return this; - } - - narrow(local: Partial): GenericOptions { - // Renaming a generic placeholder is a structural rename, not a narrow. - return { name: local.name ?? this.options.name }; - } - - /** Resolve self against the given bindings — the terminal case of the - * polymorphic Type.substitute walk. */ - substitute(bindings: Record): Type { - return bindings[this.options.name] ?? this; - } - - props(): Record { - return {}; - } - - toJSON(): TypeDef { - return { - name: GenericType.NAME, - options: { name: this.options.name }, - }; - } - - clone(): GenericType { - return new GenericType(this.registry, { ...this.options }); - } - - toCode(): string { return this.docsPrefix() + this.options.name; } - - toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { - // Unbound placeholder — no concrete shape constraint. Callers that - // need tight schemas should `.bind()` the generic first. - return this.describeType(z.any(), opts); - } - - /** Unbound generic — its instance schema mirrors `any`: accepts any TypeDef. */ - toInstanceSchema(): z.ZodTypeAny { - return z.object({ name: z.string() }).passthrough(); - } -} diff --git a/packages/gin/src/types/iface.ts b/packages/gin/src/types/iface.ts index 42d15d2..df3ba16 100644 --- a/packages/gin/src/types/iface.ts +++ b/packages/gin/src/types/iface.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { @@ -34,11 +34,12 @@ export class IfaceType extends Type> { readonly _get?: GetSet; readonly _call?: Call; - static from(json: TypeDef, registry: Registry): IfaceType { - return new IfaceType(registry, { - props: json.props ? decodeProps(json.props, registry) : {}, - get: json.get ? decodeGetSet(json.get, registry) : undefined, - call: json.call ? decodeCall(json.call, registry) : undefined, + static from(json: TypeDef, scope: TypeScope): IfaceType { + const registry = scope.registry; + return new IfaceType(scope, { + props: json.props ? decodeProps(json.props, scope) : {}, + get: json.get ? decodeGetSet(json.get, scope) : undefined, + call: json.call ? decodeCall(json.call, scope) : undefined, }); } @@ -56,10 +57,10 @@ export class IfaceType extends Type> { } constructor( - registry: Registry, + scope: TypeScope, spec: { props?: Record; get?: GetSet; call?: Call }, ) { - super(registry, {}); + super(scope, {}); const p: Record = {}; if (spec.props) { for (const [k, v] of Object.entries(spec.props)) p[k] = Prop.from(v); @@ -69,17 +70,17 @@ export class IfaceType extends Type> { this._call = spec.call; } - valid(_raw: unknown): _raw is any { + valid(_raw: unknown, _scope?: TypeScope): _raw is any { // Runtime values don't directly "satisfy" interfaces — interface // satisfaction is a TYPE-level check (see compatible()). return true; } - parse(json: unknown): Value { + parse(json: unknown, _scope?: TypeScope): Value { return new Value(this, json); } - encode(raw: any): any { + encode(raw: any, _scope?: TypeScope): any { return raw; } @@ -122,26 +123,26 @@ export class IfaceType extends Type> { }); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { // "other satisfies this interface" — structural. - const theirProps = other.props(); + const theirProps = other.props(scope); for (const [name, prop] of Object.entries(this._props)) { const their = theirProps[name]; if (!their) return false; - if (!prop.type.compatible(their.type, opts)) return false; + if (!prop.type.compatible(their.type, opts, scope)) return false; } if (this._get) { - const their = other.get(); + const their = other.get(scope); if (!their) return false; - if (!this._get.key.compatible(their.key, opts)) return false; - if (!this._get.value.compatible(their.value, opts)) return false; + if (!this._get.key.compatible(their.key, opts, scope)) return false; + if (!this._get.value.compatible(their.value, opts, scope)) return false; } if (this._call) { - const their = other.call(); + const their = other.call(scope); if (!their) return false; - if (!this._call.args.compatible(their.args, opts)) return false; + if (!this._call.args.compatible(their.args, opts, scope)) return false; if (this._call.returns && their.returns) { - if (!this._call.returns.compatible(their.returns, opts)) return false; + if (!this._call.returns.compatible(their.returns, opts, scope)) return false; } } return true; @@ -171,15 +172,15 @@ export class IfaceType extends Type> { return {}; } - props(): Record { - return { ...(super.props() as Record), ...this._props }; + props(scope?: TypeScope): Record { + return { ...(super.props(scope) as Record), ...this._props }; } - get(): GetSet | undefined { + get(_scope?: TypeScope): GetSet | undefined { return this._get; } - call(): Call | undefined { + call(_scope?: TypeScope): Call | undefined { return this._call; } diff --git a/packages/gin/src/types/index.ts b/packages/gin/src/types/index.ts index 0f42f41..00e1d3e 100644 --- a/packages/gin/src/types/index.ts +++ b/packages/gin/src/types/index.ts @@ -1,3 +1,4 @@ +export { AliasType, type AliasOptions } from './alias'; export { AnyType } from './any'; export { AndType, type AndOptions } from './and'; export { BoolType } from './bool'; @@ -6,7 +7,6 @@ export { DateType } from './date'; export { DurationType } from './duration'; export { EnumType, type EnumOptions } from './enum'; export { FnType } from './fn'; -export { GenericType, type GenericOptions } from './generic'; export { IfaceType } from './iface'; export { LiteralType, type LiteralOptions } from './literal'; export { ListType } from './list'; @@ -18,7 +18,6 @@ export { NumType } from './num'; export { ObjType } from './obj'; export { OptionalType } from './optional'; export { OrType, type OrOptions } from './or'; -export { RefType, type RefOptions } from './ref'; export { TextType } from './text'; export { TimestampType } from './timestamp'; export { TupleType, type TupleOptions } from './tuple'; diff --git a/packages/gin/src/types/list.ts b/packages/gin/src/types/list.ts index 0886964..60219da 100644 --- a/packages/gin/src/types/list.ts +++ b/packages/gin/src/types/list.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -23,9 +23,10 @@ export class ListType extends Type { static readonly NAME = 'list'; readonly name = ListType.NAME; - static from(json: TypeDef, registry: Registry): ListType { - const item = json.generic?.V ? registry.parse(json.generic.V) : registry.any(); - return new ListType(registry, item, (json.options ?? {}) as ListOptions); + static from(json: TypeDef, scope: TypeScope): ListType { + const registry = scope.registry; + const item = json.generic?.V ? scope.parse(json.generic.V) : registry.any(); + return new ListType(scope, item, (json.options ?? {}) as ListOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -44,31 +45,31 @@ export class ListType extends Type { return z.array(opts.Expr); } - constructor(registry: Registry, item: Type, options: ListOptions = {}) { - super(registry, options, { V: item }); + constructor(scope: TypeScope, item: Type, options: ListOptions = {}) { + super(scope, options, { V: item }); } get item(): Type { return this.generic.V as Type; } - valid(raw: unknown): raw is Value[] { + valid(raw: unknown, scope?: TypeScope): raw is Value[] { if (!Array.isArray(raw)) return false; const { minLength, maxLength } = this.options; if (minLength !== undefined && raw.length < minLength) return false; if (maxLength !== undefined && raw.length > maxLength) return false; - return raw.every((x) => x instanceof Value && x.type.valid(x.raw)); + return raw.every((x) => x instanceof Value && x.type.valid(x.raw, scope)); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { if (!Array.isArray(json)) { throw new TypeError({ path: [], code: 'list.invalid', message: `list.parse: expected array, got ${typeof json}`, severity: 'error', }); } - const raw: Value[] = json.map((x) => this.registry.parseValue(x, this.item)); - if (!this.valid(raw)) { + const raw: Value[] = json.map((x) => this.registry.parseValue(x, this.item, scope)); + if (!this.valid(raw, scope)) { throw new TypeError({ path: [], code: 'list.constraint', message: 'list.parse: length constraints violated', severity: 'error', @@ -79,7 +80,7 @@ export class ListType extends Type { /** Each element becomes a `JSONValue` envelope so nested subtypes * round-trip through JSON. */ - encode(raw: Value[]): JSONValue[] { + encode(raw: Value[], _scope?: TypeScope): JSONValue[] { return raw.map((v) => v.toJSON()); } @@ -102,9 +103,9 @@ export class ListType extends Type { return this.registry.list(item); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof ListType)) return false; - if (!this.item.compatible(other.item, opts)) return false; + if (!this.item.compatible(other.item, opts, scope)) return false; if (!opts?.value) return true; const a = this.options, b = other.options; if (a.minLength !== undefined && (b.minLength === undefined || b.minLength < a.minLength)) return false; @@ -194,10 +195,10 @@ export class ListType extends Type { unique: r.method({}, lstV, 'list.unique'), duplicates: r.method({}, lstV, 'list.duplicates'), - map: r.method({ fn: fnValueIndex(r.generic('R')) }, r.list(r.generic('R')), 'list.map', { generic: { R: r.any() } }), + map: r.method({ fn: fnValueIndex(r.alias('R')) }, r.list(r.alias('R')), 'list.map', { generic: { R: r.any() } }), filter: r.method({ fn: fnValueIndex(bool) }, lstV, 'list.filter'), find: r.method({ fn: fnValueIndex(bool) }, optV, 'list.find'), - reduce: r.method({ fn: r.fn(r.obj({ acc: { type: r.generic('R') }, value: { type: V }, index: { type: num } }), r.generic('R')), initial: r.generic('R') }, r.generic('R'), 'list.reduce', { generic: { R: r.any() } }), + reduce: r.method({ fn: r.fn(r.obj({ acc: { type: r.alias('R') }, value: { type: V }, index: { type: num } }), r.alias('R')), initial: r.alias('R') }, r.alias('R'), 'list.reduce', { generic: { R: r.any() } }), some: r.method({ fn: fnValueIndex(bool) }, bool, 'list.some'), every: r.method({ fn: fnValueIndex(bool) }, bool, 'list.every'), sort: r.method({ fn: r.optional(r.fn(r.obj({ a: { type: V }, b: { type: V } }), num)) }, lstV, 'list.sort'), diff --git a/packages/gin/src/types/literal.ts b/packages/gin/src/types/literal.ts index 840bb54..9f43d72 100644 --- a/packages/gin/src/types/literal.ts +++ b/packages/gin/src/types/literal.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type PropSpec, type Rnd, Type, optionsCode } from '../type'; @@ -34,10 +34,11 @@ export class LiteralType extends Type> { readonly inner: Type; - static from(json: TypeDef, registry: Registry): LiteralType { - const inner = json.generic?.T ? registry.parse(json.generic.T) : registry.any(); + static from(json: TypeDef, scope: TypeScope): LiteralType { + const registry = scope.registry; + const inner = json.generic?.T ? scope.parse(json.generic.T) : registry.any(); const value = (json.options as { value?: unknown } | undefined)?.value; - return new LiteralType(registry, inner, value); + return new LiteralType(scope, inner, value); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -54,8 +55,8 @@ export class LiteralType extends Type> { return z.any(); } - constructor(registry: Registry, inner: Type, value: T) { - super(registry, { value }, { T: inner }); + constructor(scope: TypeScope, inner: Type, value: T) { + super(scope, { value }, { T: inner }); this.inner = inner; } @@ -63,12 +64,12 @@ export class LiteralType extends Type> { return this.options.value; } - valid(raw: unknown): raw is RuntimeOf { - return this.inner.valid(raw) && raw === this.literal; + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return this.inner.valid(raw, scope) && raw === this.literal; } - parse(json: unknown): Value { - const inner = this.inner.parse(json); + parse(json: unknown, scope?: TypeScope): Value { + const inner = this.inner.parse(json, scope); if (inner.raw !== this.literal) { throw new TypeError({ path: [], code: 'literal.not-match', @@ -79,8 +80,8 @@ export class LiteralType extends Type> { return new Value(this, inner.raw); } - encode(raw: RuntimeOf): JSONOf { - return this.inner.encode(raw); + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { + return this.inner.encode(raw, scope); } create(): RuntimeOf { @@ -91,13 +92,13 @@ export class LiteralType extends Type> { return this.literal as RuntimeOf; } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (other instanceof LiteralType) { - return this.inner.compatible(other.inner, opts) && this.literal === other.literal; + return this.inner.compatible(other.inner, opts, scope) && this.literal === other.literal; } if (opts?.exact) return false; // Literal is compatible with its inner type (a literal IS a value of inner). - return this.inner.compatible(other, opts); + return this.inner.compatible(other, opts, scope); } /** literal (canonical with no declared value) delegates to any — too broad. */ diff --git a/packages/gin/src/types/map.ts b/packages/gin/src/types/map.ts index 322c7d9..2a29569 100644 --- a/packages/gin/src/types/map.ts +++ b/packages/gin/src/types/map.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; @@ -22,10 +22,11 @@ export class MapType extends Type, Record extends Type, Record, value: Type) { - super(registry, {}, { K: key, V: value }); + constructor(scope: TypeScope, key: Type, value: Type) { + super(scope, {}, { K: key, V: value }); } get key(): Type { @@ -53,18 +54,18 @@ export class MapType extends Type, Record; } - valid(raw: unknown): raw is Map, Value]> { + valid(raw: unknown, scope?: TypeScope): raw is Map, Value]> { if (!(raw instanceof Map)) return false; for (const [, entry] of raw as Map) { if (!Array.isArray(entry) || entry.length !== 2) return false; const [kv, vv] = entry; if (!(kv instanceof Value) || !(vv instanceof Value)) return false; - if (!kv.type.valid(kv.raw) || !vv.type.valid(vv.raw)) return false; + if (!kv.type.valid(kv.raw, scope) || !vv.type.valid(vv.raw, scope)) return false; } return true; } - parse(json: unknown): Value> { + parse(json: unknown, scope?: TypeScope): Value> { if (!Array.isArray(json)) { throw new TypeError({ path: [], code: 'map.invalid', @@ -77,8 +78,8 @@ export class MapType extends Type, Record = this.registry.parseValue(rawK, this.key); - const valV: Value = this.registry.parseValue(rawV, this.value); + const keyV: Value = this.registry.parseValue(rawK, this.key, scope); + const valV: Value = this.registry.parseValue(rawV, this.value, scope); m.set(keyV.raw, [keyV, valV]); } return new Value(this, m); @@ -87,7 +88,7 @@ export class MapType extends Type, Record, Value]>): Array<{ key: JSONValue; value: JSONValue }> { + encode(raw: Map, Value]>, _scope?: TypeScope): Array<{ key: JSONValue; value: JSONValue }> { return Array.from(raw, ([, [kv, vv]]) => ({ key: kv.toJSON(), value: vv.toJSON() })); } @@ -114,9 +115,9 @@ export class MapType extends Type, Record>): Type> { diff --git a/packages/gin/src/types/not.ts b/packages/gin/src/types/not.ts index 88cb031..6e515f3 100644 --- a/packages/gin/src/types/not.ts +++ b/packages/gin/src/types/not.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; @@ -19,11 +19,12 @@ export class NotType extends Type { static readonly NAME = 'not'; readonly name = NotType.NAME; - static from(json: TypeDef, registry: Registry): NotType { + static from(json: TypeDef, scope: TypeScope): NotType { + const registry = scope.registry; const excluded = json.options?.excluded - ? registry.parse(json.options.excluded) + ? scope.parse(json.options.excluded) : registry.any(); - return new NotType(registry, excluded); + return new NotType(scope, excluded); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -35,16 +36,16 @@ export class NotType extends Type { static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.any(); } - constructor(registry: Registry, readonly excluded: Type) { - super(registry, { excluded: excluded.toJSON() }); + constructor(scope: TypeScope, readonly excluded: Type) { + super(scope, { excluded: excluded.toJSON() }); } - valid(raw: unknown): raw is any { - return !this.excluded.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is any { + return !this.excluded.valid(raw, scope); } - parse(json: unknown): Value { - if (this.excluded.valid(json)) { + parse(json: unknown, scope?: TypeScope): Value { + if (this.excluded.valid(json, scope)) { throw new TypeError({ path: [], code: 'not.excluded', message: `not: value matches excluded type ${this.excluded.name}`, severity: 'error', @@ -53,7 +54,7 @@ export class NotType extends Type { return new Value(this, json); } - encode(raw: any): any { + encode(raw: any, _scope?: TypeScope): any { return raw; } @@ -72,10 +73,10 @@ export class NotType extends Type { return this.registry.not(excluded); } - compatible(other: Type, opts?: CompatOptions): boolean { - if (opts?.exact) return other instanceof NotType && this.excluded.exact(other.excluded); + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { + if (opts?.exact) return other instanceof NotType && this.excluded.exact(other.excluded, scope); // other must NOT be structurally compatible with excluded. - return !this.excluded.compatible(other, opts); + return !this.excluded.compatible(other, opts, scope); } flexible(): boolean { diff --git a/packages/gin/src/types/null.ts b/packages/gin/src/types/null.ts index 129b5d7..f548cd9 100644 --- a/packages/gin/src/types/null.ts +++ b/packages/gin/src/types/null.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; @@ -16,8 +16,9 @@ export class NullType extends Type> { static readonly NAME = 'null'; readonly name = NullType.NAME; - static from(_json: TypeDef, registry: Registry): NullType { - return new NullType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): NullType { + const registry = scope.registry; + return new NullType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/nullable.ts b/packages/gin/src/types/nullable.ts index f070458..0d13e4d 100644 --- a/packages/gin/src/types/nullable.ts +++ b/packages/gin/src/types/nullable.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; @@ -17,11 +17,12 @@ export class NullableType extends Type> static readonly NAME = 'nullable'; readonly name = NullableType.NAME; - static from(json: TypeDef, registry: Registry): NullableType { + static from(json: TypeDef, scope: TypeScope): NullableType { + const registry = scope.registry; const inner = json.generic?.T - ? registry.parse(json.generic.T) + ? scope.parse(json.generic.T) : registry.any(); - return new NullableType(registry, inner); + return new NullableType(scope, inner); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -35,23 +36,23 @@ export class NullableType extends Type> return opts.Expr.nullable(); } - constructor(registry: Registry, readonly inner: Type) { - super(registry, {}, { T: inner }); + constructor(scope: TypeScope, readonly inner: Type) { + super(scope, {}, { T: inner }); } - valid(raw: unknown): raw is RuntimeOf { - return raw === null || this.inner.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return raw === null || this.inner.valid(raw, scope); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { if (json === null) return new Value(this, null as RuntimeOf); - const v = this.inner.parse(json); + const v = this.inner.parse(json, scope); return new Value(this, v.raw as RuntimeOf); } - encode(raw: RuntimeOf): JSONOf { + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { if (raw === null) return null as JSONOf; - return this.inner.encode(raw as RuntimeOf) as JSONOf; + return this.inner.encode(raw as RuntimeOf, scope) as JSONOf; } create(): RuntimeOf { @@ -70,12 +71,12 @@ export class NullableType extends Type> return this.registry.nullable(inner); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (other instanceof NullableType) { - return this.inner.compatible(other.inner, opts); + return this.inner.compatible(other.inner, opts, scope); } if (opts?.exact) return false; - return this.inner.compatible(other, opts); + return this.inner.compatible(other, opts, scope); } or(other: Type): Type { @@ -111,7 +112,7 @@ export class NullableType extends Type> value: r.prop(T, 'nullable.value'), isNull: r.method({}, r.bool(), 'nullable.isNull'), or: r.method({ fallback: T }, T, 'nullable.or'), - map: r.method({ fn: r.fn(r.obj({ value: { type: T } }), r.generic('R')) }, r.nullable(r.generic('R')), 'nullable.map', { generic: { R: r.any() } }), + map: r.method({ fn: r.fn(r.obj({ value: { type: T } }), r.alias('R')) }, r.nullable(r.alias('R')), 'nullable.map', { generic: { R: r.any() } }), }; } diff --git a/packages/gin/src/types/num.ts b/packages/gin/src/types/num.ts index 1b0f068..2ab1b7a 100644 --- a/packages/gin/src/types/num.ts +++ b/packages/gin/src/types/num.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -22,8 +22,9 @@ export class NumType extends Type { static readonly NAME = 'num'; readonly name = NumType.NAME; - static from(json: TypeDef, registry: Registry): NumType { - return new NumType(registry, (json.options ?? {}) as NumOptions); + static from(json: TypeDef, scope: TypeScope): NumType { + const registry = scope.registry; + return new NumType(scope, (json.options ?? {}) as NumOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/obj.ts b/packages/gin/src/types/obj.ts index 97d87a0..7c6c248 100644 --- a/packages/gin/src/types/obj.ts +++ b/packages/gin/src/types/obj.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef, PropDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, Prop, type PropSpec, type Rnd, Type } from '../type'; @@ -23,10 +23,11 @@ export class ObjType> extends Type; - static from(json: TypeDef, registry: Registry): ObjType { + static from(json: TypeDef, scope: TypeScope): ObjType { + const registry = scope.registry; const fieldDefs = (json.props ?? {}) as Record; - const fields = decodeProps(fieldDefs, registry); - return new ObjType(registry, fields); + const fields = decodeProps(fieldDefs, scope); + return new ObjType(scope, fields); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,8 +43,8 @@ export class ObjType> extends Type) { - super(registry, {}); + constructor(scope: TypeScope, fields: Record) { + super(scope, {}); // Normalize plain objects to Prop instances so methods are available. const normalized: Record = {}; for (const [k, v] of Object.entries(fields)) { @@ -52,7 +53,7 @@ export class ObjType> extends Type { + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return false; for (const [name] of Object.entries(this.fields)) { const v = (raw as Record)[name]; @@ -60,12 +61,12 @@ export class ObjType> extends Type { + parse(json: unknown, scope?: TypeScope): Value { if (typeof json !== 'object' || json === null || Array.isArray(json)) { throw new TypeError({ path: [], code: 'object.invalid', @@ -75,13 +76,13 @@ export class ObjType> extends Type = {}; for (const [name, prop] of Object.entries(this.fields)) { const input = (json as Record)[name]; - raw[name] = this.registry.parseValue(input, prop.type); + raw[name] = this.registry.parseValue(input, prop.type, scope); } return new Value(this, raw as RuntimeOf); } /** Each field becomes a `JSONValue` envelope. */ - encode(raw: RuntimeOf): JSONOf { + encode(raw: RuntimeOf, _scope?: TypeScope): JSONOf { const fields = raw as Record; const out: Record = {}; for (const [name] of Object.entries(this.fields)) { @@ -118,13 +119,13 @@ export class ObjType> extends Type extends Type extends Type) { - super(registry, {}, { T: inner }); + constructor(scope: TypeScope, readonly inner: Type) { + super(scope, {}, { T: inner }); } - valid(raw: unknown): raw is RuntimeOf { - return raw === undefined || this.inner.valid(raw); + valid(raw: unknown, scope?: TypeScope): raw is RuntimeOf { + return raw === undefined || this.inner.valid(raw, scope); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { if (json === undefined || json === null) return new Value(this, undefined as RuntimeOf); - const v = this.inner.parse(json); + const v = this.inner.parse(json, scope); return new Value(this, v.raw as RuntimeOf); } - encode(raw: RuntimeOf): JSONOf { + encode(raw: RuntimeOf, scope?: TypeScope): JSONOf { if (raw === undefined) return null as JSONOf; - return this.inner.encode(raw as RuntimeOf) as JSONOf; + return this.inner.encode(raw as RuntimeOf, scope) as JSONOf; } create(): RuntimeOf { @@ -71,12 +72,12 @@ export class OptionalType extends Type): Type { @@ -117,7 +118,7 @@ export class OptionalType extends Type { static readonly NAME = 'or'; readonly name = OrType.NAME; - static from(json: TypeDef, registry: Registry): OrType { - const variants = ((json.options?.types ?? []) as TypeDef[]).map((t) => registry.parse(t)); - return new OrType(registry, variants); + static from(json: TypeDef, scope: TypeScope): OrType { + const registry = scope.registry; + const variants = ((json.options?.types ?? []) as TypeDef[]).map((t) => scope.parse(t)); + return new OrType(scope, variants); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,22 +43,22 @@ export class OrType extends Type { return opts.Expr; } - constructor(registry: Registry, variants: Type[]) { - super(registry, { variants }); + constructor(scope: TypeScope, variants: Type[]) { + super(scope, { variants }); } get variants(): Type[] { return this.options.variants; } - valid(raw: unknown): raw is any { - return this.variants.some((v) => v.valid(raw)); + valid(raw: unknown, scope?: TypeScope): raw is any { + return this.variants.some((v) => v.valid(raw, scope)); } - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { for (const v of this.variants) { try { - const parsed = v.parse(json); + const parsed = v.parse(json, scope); return new Value(this, parsed.raw); } catch { continue; @@ -70,15 +71,15 @@ export class OrType extends Type { }); } - encode(raw: any): any { - const match = this.variants.find((v) => v.valid(raw)); + encode(raw: any, scope?: TypeScope): any { + const match = this.variants.find((v) => v.valid(raw, scope)); if (!match) { throw new TypeError({ path: [], code: 'or.dump.no-match', message: 'or.dump: value does not satisfy any variant', severity: 'error', }); } - return match.encode(raw); + return match.encode(raw, scope); } create(): any { @@ -100,12 +101,12 @@ export class OrType extends Type { return this.registry.or(narrowed); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { // other is assignable to Or iff it's assignable to at least one variant. if (other instanceof OrType) { - return other.variants.every((v) => this.compatible(v, opts)); + return other.variants.every((v) => this.compatible(v, opts, scope)); } - return this.variants.some((v) => v.compatible(other, opts)); + return this.variants.some((v) => v.compatible(other, opts, scope)); } or(other: Type): Type { diff --git a/packages/gin/src/types/ref.ts b/packages/gin/src/types/ref.ts deleted file mode 100644 index 60eb133..0000000 --- a/packages/gin/src/types/ref.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { Registry } from '../registry'; -import type { PathStepDef, TypeDef } from '../schema'; -import { Value } from '../value'; -import { - type Call, - type CompatOptions, - type GetSet, - type Init, - type Prop, - type PropSpec, - type Rnd, - Type, -} from '../type'; -import { TypeError } from '../problem'; -import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; - - -export interface RefOptions { - name: string; -} - -/** - * RefType — a lazy reference to a named type registered in the Registry. - * All methods delegate to the resolved target. Used for forward references - * and for breaking potentially-cyclic type definitions. - */ -export class RefType extends Type { - static readonly NAME = 'ref'; - readonly name = RefType.NAME; - - static from(json: TypeDef, registry: Registry): RefType { - const name = (json.options?.name ?? '') as string; - return new RefType(registry, { name }); - } - - static toSchema(opts: SchemaOptions): z.ZodTypeAny { - return z.object({ - name: z.literal('ref'), - options: z.object({ name: z.string() }), - }).meta({ aid: 'Type_ref' }); - } - - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.any(); } - - private resolve(): Type { - const target = this.registry.lookup(this.options.name); - if (!target) { - throw new TypeError({ - path: [], code: 'ref.unresolved', - message: `ref.${this.options.name}: not registered`, severity: 'error', - }); - } - return target; - } - - valid(raw: unknown): raw is any { - return this.resolve().valid(raw); - } - - parse(json: unknown): Value { - const v = this.resolve().parse(json); - return new Value(this, v.raw); - } - - encode(raw: any): any { - return this.resolve().encode(raw); - } - - create(): any { - return this.resolve().create(); - } - - random(rnd: Rnd): any { - return this.resolve().random(rnd); - } - - compatible(other: Type, opts?: CompatOptions): boolean { - return this.resolve().compatible(other, opts); - } - - flexible(): boolean { - return true; - } - - or(other: Type): Type { - return this.resolve().or(other); - } - - simplify(): Type { - return this.resolve(); - } - - narrow(local: Partial): RefOptions { - if (local.name && local.name !== this.options.name) { - throw new TypeError({ - path: [], code: 'ref.rename', - message: 'ref name cannot change via narrow', severity: 'error', - }); - } - return this.options; - } - - props(): Record { - // When the ref resolves, the target's props already include the - // universal `toAny` via base.Type.props. If it can't resolve yet - // (unregistered target), fall back to the universal-only set. - try { - return this.resolve().props(); - } catch { - return super.props(); - } - } - - get(): GetSet | undefined { - try { return this.resolve().get(); } catch { return undefined; } - } - - call(): Call | undefined { - try { return this.resolve().call(); } catch { return undefined; } - } - - init(): Init | undefined { - try { return this.resolve().init(); } catch { return undefined; } - } - - follow(step: PathStepDef): Type | undefined { - return this.resolve().follow(step); - } - - toJSON(): TypeDef { - return { - name: RefType.NAME, - options: { name: this.options.name }, - }; - } - - clone(): RefType { - return new RefType(this.registry, { name: this.options.name }); - } - - toCode(): string { return this.docsPrefix() + this.options.name; } - - toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { - // Lazy so recursive named types (A → list) don't blow the stack. - return this.describeType(z.lazy(() => this.resolve().toValueSchema(opts)), opts); - } - - toNewSchema(opts: SchemaOptions): z.ZodTypeAny { - return this.describeType(z.lazy(() => this.resolve().toNewSchema(opts)), opts, 'NewValue_'); - } - - /** A ref IS a name — the instance schema is just `{name: }`. Lazy - * so self-referential named types (A → list) don't infinite-recurse. */ - toInstanceSchema(): z.ZodTypeAny { - return z.lazy(() => this.resolve().toInstanceSchema()); - } -} diff --git a/packages/gin/src/types/text.ts b/packages/gin/src/types/text.ts index b7bc1ae..0e8b237 100644 --- a/packages/gin/src/types/text.ts +++ b/packages/gin/src/types/text.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -23,8 +23,9 @@ export class TextType extends Type { private _regex?: RegExp; - static from(json: TypeDef, registry: Registry): TextType { - return new TextType(registry, (json.options ?? {}) as TextOptions); + static from(json: TypeDef, scope: TypeScope): TextType { + const registry = scope.registry; + return new TextType(scope, (json.options ?? {}) as TextOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/timestamp.ts b/packages/gin/src/types/timestamp.ts index a0b3e67..dc7ea78 100644 --- a/packages/gin/src/types/timestamp.ts +++ b/packages/gin/src/types/timestamp.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; @@ -20,8 +20,9 @@ export class TimestampType extends Type { static readonly NAME = 'timestamp'; readonly name = TimestampType.NAME; - static from(json: TypeDef, registry: Registry): TimestampType { - return new TimestampType(registry, (json.options ?? {}) as TimestampOptions); + static from(json: TypeDef, scope: TypeScope): TimestampType { + const registry = scope.registry; + return new TimestampType(scope, (json.options ?? {}) as TimestampOptions); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/tuple.ts b/packages/gin/src/types/tuple.ts index 08ec4c0..1b40110 100644 --- a/packages/gin/src/types/tuple.ts +++ b/packages/gin/src/types/tuple.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { PathStepDef, TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; @@ -23,10 +23,11 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { readonly elements: Type[]; - static from(json: TypeDef, registry: Registry): TupleType { + static from(json: TypeDef, scope: TypeScope): TupleType { + const registry = scope.registry; const defs = ((json.options?.elements ?? []) as TypeDef[]); - const elems = defs.map((d) => registry.parse(d)); - return new TupleType(registry, elems); + const elems = defs.map((d) => scope.parse(d)); + return new TupleType(scope, elems); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -43,18 +44,18 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return z.array(opts.Expr); } - constructor(registry: Registry, elements: Type[]) { - super(registry, { elements: elements.map((e) => e.toJSON()) }); + constructor(scope: TypeScope, elements: Type[]) { + super(scope, { elements: elements.map((e) => e.toJSON()) }); this.elements = elements; } - valid(raw: unknown): raw is [Value, ...Value[]] { + valid(raw: unknown, scope?: TypeScope): raw is [Value, ...Value[]] { if (!Array.isArray(raw)) return false; if (raw.length !== this.elements.length) return false; - return raw.every((v) => v instanceof Value && v.type.valid(v.raw)); + return raw.every((v) => v instanceof Value && v.type.valid(v.raw, scope)); } - parse(json: unknown): Value<[any, ...any[]]> { + parse(json: unknown, scope?: TypeScope): Value<[any, ...any[]]> { if (!Array.isArray(json) || json.length !== this.elements.length) { throw new TypeError({ path: [], code: 'tuple.invalid', @@ -62,12 +63,12 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { severity: 'error', }); } - const raw = this.elements.map((e, i) => this.registry.parseValue(json[i], e)) as [Value, ...Value[]]; + const raw = this.elements.map((e, i) => this.registry.parseValue(json[i], e, scope)) as [Value, ...Value[]]; return new Value(this, raw); } /** Each positional value becomes a `JSONValue` envelope. */ - encode(raw: [Value, ...Value[]]): [JSONValue, ...JSONValue[]] { + encode(raw: [Value, ...Value[]], _scope?: TypeScope): [JSONValue, ...JSONValue[]] { return raw.map((v) => v.toJSON()) as [JSONValue, ...JSONValue[]]; } @@ -88,10 +89,10 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return this.registry.tuple(narrowed); } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof TupleType)) return false; if (other.elements.length !== this.elements.length) return false; - return this.elements.every((e, i) => e.compatible(other.elements[i]!, opts)); + return this.elements.every((e, i) => e.compatible(other.elements[i]!, opts, scope)); } or(other: Type<[any, ...any[]]>): Type<[any, ...any[]]> { @@ -127,7 +128,7 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { }); } - follow(step: PathStepDef): Type | undefined { + follow(step: PathStepDef, scope?: TypeScope): Type | undefined { // Literal positional index → exact element type. if ('key' in step && !('args' in step)) { const key = step.key as any; @@ -136,7 +137,7 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return this.elements[rawKey]; } } - return super.follow(step); + return super.follow(step, scope); } props(): Record { diff --git a/packages/gin/src/types/typ.ts b/packages/gin/src/types/typ.ts index edbe849..bfeb472 100644 --- a/packages/gin/src/types/typ.ts +++ b/packages/gin/src/types/typ.ts @@ -1,5 +1,5 @@ +import type { TypeScope } from '../type-scope'; import { z } from 'zod'; -import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import type { SchemaOptions, ValueSchemaOptions } from '../node'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; @@ -25,9 +25,10 @@ export class TypType extends Type> { static readonly NAME = 'typ'; readonly name = TypType.NAME; - static from(json: TypeDef, registry: Registry): TypType { - const constraint = json.generic?.T ? registry.parse(json.generic.T) : registry.any(); - return new TypType(registry, constraint); + static from(json: TypeDef, scope: TypeScope): TypType { + const registry = scope.registry; + const constraint = json.generic?.T ? scope.parse(json.generic.T) : registry.any(); + return new TypType(scope, constraint); } static toSchema(opts: SchemaOptions): z.ZodTypeAny { @@ -42,29 +43,30 @@ export class TypType extends Type> { return opts.Type; } - constructor(registry: Registry, readonly constraint: Type) { - super(registry, {}, { T: constraint }); + constructor(scope: TypeScope, readonly constraint: Type) { + super(scope, {}, { T: constraint }); } /** Accepts a Type instance whose values fit (in either direction — see * note on compat asymmetry). The raw IS a Type, not JSON. */ - valid(raw: unknown): boolean { + valid(raw: unknown, scope?: TypeScope): boolean { if (!(raw instanceof Type)) return false; // Accept in either direction: `raw.compatible(constraint)` handles // Extension subtypes (Positive.compatible(num) = true via base); the // opposite direction `constraint.compatible(raw)` handles top-type // cases (any.compatible(num) = true) so `typ` accepts everything. - return raw.compatible(this.constraint) || this.constraint.compatible(raw); + return raw.compatible(this.constraint, undefined, scope) + || this.constraint.compatible(raw, undefined, scope); } /** Parse a JSON TypeDef into a Type instance, then validate against the * constraint. One-shot conversion — subsequent `.raw` access is free. */ - parse(json: unknown): Value { + parse(json: unknown, scope?: TypeScope): Value { // Passthrough: already a Value of the right shape. if (json instanceof Value && json.type instanceof TypType) return json; // Already a Type — wrap directly. if (json instanceof Type) { - if (!this.valid(json)) { + if (!this.valid(json, scope)) { throw new Error(`typ.parse: Type '${json.name}' is not compatible with ${this.constraint.toCode()}`); } return new Value(this, json); @@ -78,19 +80,19 @@ export class TypType extends Type> { } let parsed: Type; try { - parsed = this.registry.parse(json as TypeDef); + parsed = this.registry.parse(json as TypeDef, scope); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`typ.parse: ${msg}`); } - if (!this.valid(parsed)) { + if (!this.valid(parsed, scope)) { throw new Error(`typ.parse: Type '${parsed.name}' is not compatible with ${this.constraint.toCode()}`); } return new Value(this, parsed); } /** Serialize to TypeDef JSON — the wire form. */ - encode(raw: Type): TypeDef { + encode(raw: Type, _scope?: TypeScope): TypeDef { return raw.toJSON(); } @@ -102,9 +104,9 @@ export class TypType extends Type> { return this.constraint; } - compatible(other: Type, opts?: CompatOptions): boolean { + compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof TypType)) return false; - return this.constraint.compatible(other.constraint, opts); + return this.constraint.compatible(other.constraint, opts, scope); } or(other: Type): Type { diff --git a/packages/gin/src/types/void.ts b/packages/gin/src/types/void.ts index 5b53bf2..8ab9673 100644 --- a/packages/gin/src/types/void.ts +++ b/packages/gin/src/types/void.ts @@ -1,4 +1,4 @@ -import type { Registry } from '../registry'; +import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; @@ -15,8 +15,9 @@ export class VoidType extends Type> { static readonly NAME = 'void'; readonly name = VoidType.NAME; - static from(_json: TypeDef, registry: Registry): VoidType { - return new VoidType(registry, {}); + static from(_json: TypeDef, scope: TypeScope): VoidType { + const registry = scope.registry; + return new VoidType(scope, {}); } static toSchema(_opts: SchemaOptions): z.ZodTypeAny { diff --git a/packages/ginny/src/ai.ts b/packages/ginny/src/ai.ts index 86d39b3..b8766d6 100644 --- a/packages/ginny/src/ai.ts +++ b/packages/ginny/src/ai.ts @@ -11,6 +11,8 @@ import { createStore } from './store'; import { createRunState } from './run-state'; import { createFetchImpl, registerFetchType } from './natives/fetch'; import { createLlmImpl, registerLlmType } from './natives/llm'; +import { createLogImpl, registerLogType } from './natives/log'; +import { createAskImpl, registerAskType } from './natives/ask'; // Hydrate process.env from config.json before anything reads env vars. // Safe: imported modules above just declare classes; no env-var reads run yet. @@ -192,16 +194,22 @@ process.on('SIGTERM', () => { logger.close(); process.exit(0); }); // Wire global natives after AI instance is created. const fetchFnType = registerFetchType(registry); const llmFnType = registerLlmType(registry); +const logFnType = registerLogType(registry); +const askFnType = registerAskType(registry); const fnsType = registry.obj({ fetch: { type: fetchFnType }, - llm: { type: llmFnType }, + llm: { type: llmFnType }, + log: { type: logFnType, docs: 'Print a runtime message to the user (stderr). Use for progress narration or surfacing intermediate values.' }, + ask: { type: askFnType, docs: 'Pause execution and prompt the user for input. Pass `output: typ` to get a typed answer; the consumer walks the user through any complex shape (obj fields, list items, choices, etc). Put `docs` on type fields — those become the prompt labels.' }, }); engine.registerGlobal('fns', { type: fnsType, value: { fetch: createFetchImpl(registry), - llm: createLlmImpl(registry, ai), + llm: createLlmImpl(registry, ai), + log: createLogImpl(registry), + ask: createAskImpl(registry), }, }); diff --git a/packages/ginny/src/consumer.ts b/packages/ginny/src/consumer.ts new file mode 100644 index 0000000..2316ea2 --- /dev/null +++ b/packages/ginny/src/consumer.ts @@ -0,0 +1,398 @@ +import { + AliasType, + AndType, + AnyType, + BoolType, + ColorType, + DateType, + DurationType, + EnumType, + FnType, + IfaceType, + ListType, + LiteralType, + MapType, + NotType, + NullType, + NullableType, + NumType, + ObjType, + OptionalType, + OrType, + TextType, + TimestampType, + TupleType, + TypType, + Type, + Value, + val, + type Prop, + type Registry, +} from '@aeye/gin'; +import type { Extension } from '@aeye/gin'; + +/** + * Adapter the consumer uses to ask the user for input. A v1 text-only + * implementation simulates `choice`/`confirm` over the same single-line + * prompt; richer terminal UIs can drop in later without touching the + * consumer itself. + * + * Every method returns `null` to signal cancellation (Ctrl-C, blank + * answer at the top level, etc.). `consume` propagates that as `null` + * all the way up — `fns.ask` surfaces it to the program as + * `optional = null`. + */ +export interface AskAdapter { + text(p: { title: string; details?: string; default?: string }): Promise; + choice(p: { title: string; details?: string; options: string[] }): Promise; + confirm(p: { title: string; details?: string; default?: boolean }): Promise; +} + +/** Default adapter — wraps a single-line `ask(question): string` (the + * same shape `ctx.ask` exposes). Simulates choice via "1-N" picks and + * confirm via `(y/n)`. Empty answer is treated as cancellation, except + * inside `confirm` where empty falls back to the default. */ +export function textAdapter( + ask: (question: string, signal?: AbortSignal) => Promise, + signal?: AbortSignal, +): AskAdapter { + const renderHeader = (title: string, details?: string): string => + details ? `${title}\n ${details}` : title; + + return { + async text({ title, details, default: def }) { + const header = renderHeader(title, details); + const prompt = def !== undefined + ? `${header} [${def}]: ` + : `${header}: `; + const answer = await ask(prompt, signal); + const trimmed = answer.trim(); + if (trimmed === '' && def !== undefined) return def; + if (trimmed === '') return null; + return answer; + }, + + async choice({ title, details, options }) { + if (options.length === 0) return null; + if (options.length === 1) return options[0]!; + const header = renderHeader(title, details); + const list = options.map((o, i) => ` ${i + 1}) ${o}`).join('\n'); + const prompt = `${header}\n${list}\nPick (1-${options.length}): `; + const raw = (await ask(prompt, signal)).trim(); + if (raw === '') return null; + // Allow either the index or the literal label. + const n = Number(raw); + if (Number.isInteger(n) && n >= 1 && n <= options.length) { + return options[n - 1]!; + } + const match = options.find((o) => o === raw); + return match ?? null; + }, + + async confirm({ title, details, default: def }) { + const header = renderHeader(title, details); + const hint = def === true ? '(Y/n)' : def === false ? '(y/N)' : '(y/n)'; + const raw = (await ask(`${header} ${hint}: `, signal)).trim().toLowerCase(); + if (raw === '') return def ?? false; + return raw === 'y' || raw === 'yes' || raw === '1' || raw === 'true'; + }, + }; +} + +/** Caller-supplied prompt context for the top-level `consume` call. */ +export interface ConsumeOptions { + /** Headline shown before the first prompt. */ + title: string; + /** Optional supplemental description. */ + details?: string; +} + +/** + * Walk a `Type` interactively, prompting the user for whatever pieces + * the type needs and returning a parsed `Value` matching that type. + * + * Returns `null` when the user cancels at any depth — the cancellation + * unwinds the whole walk so the caller sees a single `null`. Sub-walks + * use the type's `docs` field (or a prop's `docs` for obj fields) as + * the prompt details, with the type name / field name as the title; + * authors are encouraged to put short user-facing labels in `docs`. + * + * The walker is type-class-driven (instanceof on each gin Type + * subclass) — adding a new Type means adding one more branch here. + */ +export async function consume( + type: Type, + opts: ConsumeOptions, + adapter: AskAdapter, + registry: Registry, +): Promise { + // Resolve aliases / extensions before dispatching so the structural + // form drives the flow. + const t = unwrap(type); + + // Leaf-text-like — read a string, parse via the type. Re-prompt up + // to 3 times on parse error. + if (t instanceof TextType + || t instanceof NumType + || t instanceof DateType + || t instanceof TimestampType + || t instanceof DurationType + || t instanceof ColorType) { + return promptLeaf(t, opts, adapter, 3); + } + + if (t instanceof BoolType) { + const ans = await adapter.confirm({ title: opts.title, details: detailsFor(opts.details, t) }); + return val(registry.bool(), ans); + } + + if (t instanceof NullType) return val(registry.null(), null); + if (t instanceof Type && t.name === 'void') return val(registry.void(), undefined); + if (t instanceof AnyType) { + const raw = await adapter.text({ title: opts.title, details: detailsFor(opts.details, t) }); + if (raw === null) return null; + // Try JSON, fall back to the literal string. + try { return val(registry.any(), JSON.parse(raw)); } + catch { return val(registry.any(), raw); } + } + + if (t instanceof EnumType) { + const opt = t as { options: { values: Record } }; + const labels = Object.keys(opt.options.values); + const picked = await adapter.choice({ + title: opts.title, + details: detailsFor(opts.details, t), + options: labels, + }); + if (picked === null) return null; + const value = opt.options.values[picked]; + return t.parse(value); + } + + if (t instanceof LiteralType) { + // Literal — there's only one valid value; no prompt. + return val(t, (t as unknown as { literal: unknown }).literal); + } + + if (t instanceof OptionalType || t instanceof NullableType) { + const inner = (t as unknown as { inner: Type }).inner; + const give = await adapter.confirm({ + title: `${opts.title} — provide a value?`, + details: detailsFor(opts.details, t), + }); + if (!give) { + return t instanceof OptionalType + ? val(t, undefined) + : val(t, null); + } + const inner_value = await consume(inner, { title: opts.title, details: opts.details }, adapter, registry); + if (inner_value === null) return null; + return val(t, inner_value.raw); + } + + if (t instanceof ListType) { + const item = (t as unknown as { item: Type }).item; + const itemOpts = (t as unknown as { options: { minLength?: number; maxLength?: number } }).options; + const min = itemOpts.minLength ?? 0; + const max = itemOpts.maxLength ?? Infinity; + const items: Value[] = []; + while (items.length < max) { + // Below min: don't even ask, just keep going. + // At-or-above min: confirm whether to add another. + if (items.length >= min) { + const more = await adapter.confirm({ + title: `${opts.title} — add ${items.length === 0 ? 'an' : 'another'} item?`, + details: detailsFor(opts.details, t), + default: items.length < min, + }); + if (!more) break; + } + const itemTitle = `${opts.title}[${items.length}]`; + const itemValue = await consume(item, { title: itemTitle, details: docsFor(item) }, adapter, registry); + if (itemValue === null) return null; + items.push(itemValue); + } + return val(t, items); + } + + if (t instanceof TupleType) { + const elems = (t as unknown as { elements: Type[] }).elements; + const out: Value[] = []; + for (let i = 0; i < elems.length; i++) { + const elem = elems[i]!; + const v = await consume(elem, { + title: `${opts.title}[${i}]`, + details: docsFor(elem), + }, adapter, registry); + if (v === null) return null; + out.push(v); + } + return val(t, out as [Value, ...Value[]]); + } + + if (t instanceof ObjType || t instanceof IfaceType) { + const fields = (t instanceof ObjType + ? (t as unknown as { fields: Record }).fields + : (t as unknown as { _props: Record })._props); + const out: Record = {}; + for (const [name, prop] of Object.entries(fields)) { + const v = await consume(prop.type, { + title: `${opts.title}.${name}`, + // Prop docs win over type docs — they describe THIS field's role + // in the parent shape, which is more specific. + details: prop.docs ?? docsFor(prop.type), + }, adapter, registry); + if (v === null) return null; + out[name] = v; + } + return val(t, out); + } + + if (t instanceof MapType) { + const keyT = (t as unknown as { key: Type }).key; + const valT = (t as unknown as { value: Type }).value; + const m = new Map(); + while (true) { + const more = await adapter.confirm({ + title: `${opts.title} — add ${m.size === 0 ? 'an' : 'another'} entry?`, + details: detailsFor(opts.details, t), + }); + if (!more) break; + const k = await consume(keyT, { title: `${opts.title} key`, details: docsFor(keyT) }, adapter, registry); + if (k === null) return null; + const v = await consume(valT, { title: `${opts.title} value`, details: docsFor(valT) }, adapter, registry); + if (v === null) return null; + m.set(k.raw, [k, v]); + } + return val(t, m); + } + + if (t instanceof OrType) { + const variants = (t as unknown as { variants: Type[] }).variants; + const labels = variants.map((v) => v.toCode()); + const picked = await adapter.choice({ + title: `${opts.title} — pick a variant`, + details: detailsFor(opts.details, t), + options: labels, + }); + if (picked === null) return null; + const idx = labels.indexOf(picked); + const variant = variants[idx]!; + const inner = await consume(variant, { title: opts.title, details: docsFor(variant) }, adapter, registry); + if (inner === null) return null; + return val(t, inner.raw); + } + + if (t instanceof AndType) { + // Intersection — usually structurally equal to the first part. If + // there's a more nuanced merge needed, the LLM should declare an + // Extension instead. + const parts = (t as unknown as { parts: Type[] }).parts; + const first = parts[0]; + if (!first) return val(t, null); + return consume(first, opts, adapter, registry); + } + + if (t instanceof NotType) { + // Can't generate a "not-X" UI. Fall back to text + permissive parse. + const raw = await adapter.text({ title: opts.title, details: detailsFor(opts.details, t) }); + if (raw === null) return null; + return val(t, raw); + } + + if (t instanceof TypType) { + // v1: pick a registered type by name. Inline-Extension authoring + // is out of scope. + const names = registry.namedTypeList().map((nt) => nt.name); + const builtins = registry.typeClasses().map((c) => c.NAME); + const all = Array.from(new Set([...names, ...builtins])).sort(); + const picked = await adapter.choice({ + title: `${opts.title} — pick a type`, + details: detailsFor(opts.details, t), + options: all, + }); + if (picked === null) return null; + return t.parse({ name: picked }); + } + + if (t instanceof FnType) { + throw new Error(`fns.ask: cannot prompt for a function type — ${t.toCode()}`); + } + + // Unknown leaf. Best effort: text + parse. + return promptLeaf(t, opts, adapter, 3); +} + +/** Read a string, parse via the type. Re-prompt up to `maxAttempts` + * times on parse error, surfacing the parser's message. */ +async function promptLeaf( + t: Type, + opts: ConsumeOptions, + adapter: AskAdapter, + maxAttempts: number, +): Promise { + let lastError = ''; + for (let i = 0; i < maxAttempts; i++) { + const details = lastError + ? `${opts.details ?? docsFor(t) ?? ''}\n (last attempt: ${lastError})`.trim() + : detailsFor(opts.details, t); + const raw = await adapter.text({ title: opts.title, details }); + if (raw === null) return null; + try { + // Heuristic: numeric-leafs accept both numeric strings and JSON. + // For text-leafs the raw string IS the answer. + const parsed = parseLeafInput(t, raw); + return t.parse(parsed); + } catch (e: unknown) { + lastError = e instanceof Error ? e.message : String(e); + } + } + return null; +} + +function parseLeafInput(t: Type, raw: string): unknown { + if (t instanceof NumType) { + const n = Number(raw); + if (!Number.isFinite(n)) throw new Error(`'${raw}' is not a number`); + return n; + } + return raw; +} + +/** Resolve aliases / unwrap extensions to the structural form so the + * walker dispatches on the underlying class. */ +function unwrap(t: Type): Type { + // AliasType.simplify resolves through scope to the target. + let cur: Type = t; + if (cur instanceof AliasType) { + cur = cur.simplify(); + } + // Extension — defer to base for the structural shape. The + // Extension's narrowed options ride along through `parse`, so the + // value the consumer constructs still gets validated against the + // Extension's constraints when the caller (fns.ask) wraps the final + // value in `t.parse(...)`. + if (isExtension(cur)) { + return unwrap(cur.base); + } + return cur; +} + +function isExtension(t: Type): t is Extension { + // Avoid importing Extension just for the check at runtime (it's + // also fine to import — but this keeps the consumer's surface + // narrow). Identify Extensions by their `base` field shape. + return 'base' in (t as object) && t.constructor.name === 'Extension'; +} + +/** Pick a useful `details` string: caller-supplied wins, type docs + * next, blank otherwise. */ +function detailsFor(callerDetails: string | undefined, t: Type): string | undefined { + if (callerDetails && callerDetails.length > 0) return callerDetails; + return docsFor(t); +} + +function docsFor(t: Type): string | undefined { + const d = (t as unknown as { docs?: string }).docs; + return d && d.length > 0 ? d : undefined; +} diff --git a/packages/ginny/src/context.ts b/packages/ginny/src/context.ts index be952be..9025dd7 100644 --- a/packages/ginny/src/context.ts +++ b/packages/ginny/src/context.ts @@ -30,6 +30,24 @@ export interface Ctx { * write the function inline. */ programmerDepth?: number; + /** + * The user's original top-level request, captured by the entry point + * before launching the depth-0 programmer. Plumbed through every + * recursive engineer/programmer pair so a deep programmer can render + * "what is this work ultimately for" alongside its own immediate + * task. Empty for non-interactive entry points that didn't bother to + * set it. + */ + originalRequest?: string; + /** + * Call-chain ancestry for recursive programmers, oldest → newest. + * Each entry is a function the engineer was asked to create at one + * level of nesting. Empty at depth 0; appended once per + * `engineer.create_new_fn` before spawning the inner programmer. A + * programmer at depth N reads the chain to understand which caller + * needs its function and why — so it can stay scoped to that need. + */ + programmerChain?: ProgrammerChainEntry[]; /** * Set by `engineer.create_new_fn` before invoking the inner programmer. * Tells `test()` how to wrap raw scope args into typed `Value`s and @@ -71,4 +89,21 @@ export interface Ctx { * programmers in the stack. */ export const MAX_PROGRAMMER_DEPTH = 3; +/** + * One step in the programmer call-chain — recorded by the engineer at + * each `create_new_fn`. The chain lets a deep programmer reason about + * which parent function depends on its output and what the original + * user request was, instead of seeing only its own isolated signature. + */ +export interface ProgrammerChainEntry { + /** Function name (matches what `finish({ saveAs })` will use). */ + name: string; + /** `argsType.toCode()` — human-readable parameter shape. */ + argsCode: string; + /** `returnsType.toCode()` — human-readable return shape. */ + returnsCode: string; + /** The engineer's `description` input — what this function should do. */ + description: string; +} + export interface Meta {} diff --git a/packages/ginny/src/index.ts b/packages/ginny/src/index.ts index 004a066..418ff6f 100644 --- a/packages/ginny/src/index.ts +++ b/packages/ginny/src/index.ts @@ -109,7 +109,15 @@ async function runRequest(request: string): Promise { const events = programmer.get( 'stream', {}, - { signal: abort.signal, messages: history, ask: askUser }, + { + signal: abort.signal, + messages: history, + ask: askUser, + // Top-level request — propagates down through every recursive + // engineer/programmer pair so deep programmers know what the + // user originally asked for, not just their immediate task. + originalRequest: request, + }, ); for await (const event of events) { // Keep the "ginny is thinking…" spinner alive until the model diff --git a/packages/ginny/src/logger.ts b/packages/ginny/src/logger.ts index 5fec0be..ed61005 100644 --- a/packages/ginny/src/logger.ts +++ b/packages/ginny/src/logger.ts @@ -2,16 +2,23 @@ import fs from 'fs'; import path from 'path'; /** - * Append-only logger that writes to `./ginny.log` in the session CWD. + * Per-startup logger that writes to `./ginny.log` in the session CWD. * Wired into AI hooks + sub-agent invocations so every LLM request and * response is captured for later inspection / debugging. + * + * Truncated at startup — each ginny invocation gets a fresh log so the + * file reflects only the current session. Older sessions roll off + * naturally; if you need history, copy the file before launching ginny + * again. */ export class Logger { private stream: fs.WriteStream; constructor(cwd: string) { const filePath = path.join(cwd, 'ginny.log'); - this.stream = fs.createWriteStream(filePath, { flags: 'a' }); + // 'w' truncates on open (vs. 'a' which appends). One file per + // session keeps the post-mortem signal-to-noise ratio high. + this.stream = fs.createWriteStream(filePath, { flags: 'w' }); this.log(`=== ginny session start: ${new Date().toISOString()} ===`); } diff --git a/packages/ginny/src/natives/ask.ts b/packages/ginny/src/natives/ask.ts new file mode 100644 index 0000000..0f830a7 --- /dev/null +++ b/packages/ginny/src/natives/ask.ts @@ -0,0 +1,116 @@ +import type { Registry, Type, Value } from '@aeye/gin'; +import { val } from '@aeye/gin'; +import { consume, textAdapter } from '../consumer'; + +/** + * Per-process current ask handler. The natives are registered ONCE at + * startup, but the user-prompt function is per-conversation. Tools + * that drive program execution (`test`, `finish`, anything calling + * `engine.run`/lambda evaluation) install the current handler via + * `withAskHandler` for the duration of the run. + * + * Single-threaded by design: ginny processes one request at a time, + * so a module-level slot is reentrancy-safe enough. + */ +type AskFn = (question: string, signal?: AbortSignal) => Promise; +let currentAsk: AskFn | null = null; +let currentSignal: AbortSignal | undefined; + +export function setAskHandler(fn: AskFn | null, signal?: AbortSignal): void { + currentAsk = fn; + currentSignal = signal; +} + +export async function withAskHandler( + fn: AskFn | null | undefined, + signal: AbortSignal | undefined, + body: () => Promise, +): Promise { + const prevFn = currentAsk; + const prevSig = currentSignal; + currentAsk = fn ?? null; + currentSignal = signal; + try { + return await body(); + } finally { + currentAsk = prevFn; + currentSignal = prevSig; + } +} + +/** + * `fns.ask({ title, details, output? }): optional` — pause + * the program and walk the user through entering a value of `output`'s + * type. When `output` is omitted, returns plain text. Returns `null` + * when the user cancels. + * + * The walker (`consume` in `../consumer.ts`) honors each (sub)type's + * `docs` field as the user-facing label — programs that want + * meaningful prompts should put short descriptions on their type + * fields. For complex shapes (list of objects, etc.) the user is + * walked through item-by-item / field-by-field. + */ +export function createAskImpl(registry: Registry) { + return async (argsValue: Value): Promise => { + const args = argsValue.raw as Record; + const title = (args['title']?.raw ?? '') as string; + const details = (args['details']?.raw ?? '') as string; + const outputType = args['output']?.raw as Type | undefined; + + if (!currentAsk) { + throw new Error( + 'fns.ask: no ask handler installed — this native is only available ' + + 'inside a ginny conversation (test/finish/run). Direct programmatic ' + + 'use must wrap engine.run in `withAskHandler(askFn, signal, () => …)`.', + ); + } + + const adapter = textAdapter(currentAsk, currentSignal); + + // No output type — fall back to a single text prompt. Mirrors the + // `text` default on the schema's R generic. + if (!outputType) { + const raw = await adapter.text({ title, details }); + if (raw === null) { + return val(registry.optional(registry.text()), undefined); + } + return val(registry.optional(registry.text()), raw); + } + + const result = await consume( + outputType, + { title, details }, + adapter, + registry, + ); + if (result === null) { + return val(registry.optional(outputType), undefined); + } + // Re-wrap: the consumer returned a Value of the inner type. We + // need to surface it as `optional`-typed so the program reads + // it as such (the declared return type of `fns.ask`). + return val(registry.optional(outputType), result.raw); + }; +} + +export function registerAskType(registry: Registry) { + return registry.fn( + registry.obj({ + title: { + type: registry.text(), + docs: 'Short headline shown to the user before any prompts. Describes WHAT you\'re asking for.', + }, + details: { + type: registry.text(), + docs: 'Supplemental context — why you\'re asking, what the answer will be used for, formatting hints.', + }, + output: { + type: registry.optional(registry.typ(registry.alias('R'))), + docs: 'Optional gin Type the answer must conform to. Use rich shapes (obj / list / enum / optional) and put `docs` on every field — those docs become the user-facing label for each sub-prompt. Omit to read plain text.', + }, + }), + registry.optional(registry.alias('R')), + undefined, + { R: registry.text() }, + ); +} diff --git a/packages/ginny/src/natives/fetch.ts b/packages/ginny/src/natives/fetch.ts index a0d7109..cb7cea8 100644 --- a/packages/ginny/src/natives/fetch.ts +++ b/packages/ginny/src/natives/fetch.ts @@ -59,11 +59,11 @@ export function registerFetchType(registry: Registry) { headers: { type: registry.optional(registry.map(registry.text(), registry.text())) }, body: { type: registry.optional(registry.any()) }, output: { - type: registry.optional(registry.typ(registry.generic('R'))), + type: registry.optional(registry.typ(registry.alias('R'))), docs: 'gin Type to parse the JSON response body through — unifies R in the return type.', }, }), - registry.generic('R'), + registry.alias('R'), undefined, { R: registry.text() }, ); diff --git a/packages/ginny/src/natives/llm.ts b/packages/ginny/src/natives/llm.ts index c7f6154..100613b 100644 --- a/packages/ginny/src/natives/llm.ts +++ b/packages/ginny/src/natives/llm.ts @@ -40,11 +40,11 @@ export function registerLlmType(registry: Registry) { prompt: { type: registry.text() }, tools: { type: registry.optional(registry.list(registry.any())) }, output: { - type: registry.optional(registry.typ(registry.generic('R'))), + type: registry.optional(registry.typ(registry.alias('R'))), docs: 'gin Type to parse the LLM response through — unifies R in the return type.', }, }), - registry.generic('R'), + registry.alias('R'), undefined, { R: registry.text() }, ); diff --git a/packages/ginny/src/natives/log.ts b/packages/ginny/src/natives/log.ts new file mode 100644 index 0000000..fb38d32 --- /dev/null +++ b/packages/ginny/src/natives/log.ts @@ -0,0 +1,53 @@ +import type { Registry, Value } from '@aeye/gin'; +import { val } from '@aeye/gin'; + +/** + * `fns.log({ message: any }): void` — print a runtime message to the + * user. Distinct from the program's return value, which is the + * computed result; `log` is the side-channel a program uses to narrate + * progress or surface intermediate findings. + * + * Output goes to stderr with a `[log]` prefix so it doesn't blur with + * the diagnostic stream `write`/`test` use. The body is rendered: + * - text → as-is + * - num / bool / null → String(...) + * - timestamp / date / duration / color → Value.toString-equivalent + * - everything else → JSON of the JSONValue envelope + */ +export function createLogImpl(registry: Registry) { + return async (argsValue: Value): Promise => { + const args = argsValue.raw as Record; + const message = args['message']; + process.stderr.write(`[log] ${formatMessage(message)}\n`); + return val(registry.void(), undefined); + }; +} + +export function registerLogType(registry: Registry) { + return registry.fn( + registry.obj({ + message: { + type: registry.any(), + docs: 'Anything to surface to the user — a status update, intermediate value, debug breadcrumb. Renders as text on stderr; complex values JSON-encode.', + }, + }), + registry.void(), + ); +} + +function formatMessage(v: Value | undefined): string { + if (!v) return ''; + const raw = v.raw; + if (raw === null || raw === undefined) return String(raw); + if (typeof raw === 'string') return raw; + if (typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'bigint') { + return String(raw); + } + // Composite or unknown — try a JSON envelope. Fall back to String() if + // anything throws (e.g. circular refs, BigInt without serializer). + try { + return JSON.stringify(v.toJSON(), null, 2); + } catch { + return String(raw); + } +} diff --git a/packages/ginny/src/prompts/engineer.ts b/packages/ginny/src/prompts/engineer.ts index 53cb3ab..cd3fd11 100644 --- a/packages/ginny/src/prompts/engineer.ts +++ b/packages/ginny/src/prompts/engineer.ts @@ -6,7 +6,7 @@ import { ai } from '../ai'; import { modelFor } from '../model-selection'; import { ask } from '../tools/ask'; import { runSubagent } from '../progress'; -import { MAX_PROGRAMMER_DEPTH } from '../context'; +import { MAX_PROGRAMMER_DEPTH, type ProgrammerChainEntry } from '../context'; import { createRunState } from '../run-state'; // `programmer` and `engineer` form a circular import (programmer ↔ // findOrCreateFunctions → engineer → createNewFn → programmer). The @@ -69,7 +69,7 @@ const createNewFn = ai.tool({ 'Optional `call.types` aliases — declare reusable named types here ONCE and reference them inside `args` / `returns` as a bare `{name: ""}`. ' + 'Use whenever the same composite type would appear more than once in the signature. ' + 'Example: `{ "positiveInt": { "name": "num", "options": { "whole": true, "min": 1 } } }` lets you write `args: { name: "obj", props: { n: { type: { name: "positiveInt" } } } }` and `returns: { name: "list", generic: { V: { name: "positiveInt" } } }` — instead of repeating the full options block twice. ' + - 'Sequential: later aliases may reference earlier. Forward / self references throw. Alias names cannot match a built-in type-class name (`num`, `list`, `obj`, etc.).', + 'Sequential: later aliases may reference earlier. Pick alias names that don\'t collide with built-in type-class names (`num`, `list`, `obj`, etc.) — bare `{name: "num"}` always resolves to the built-in num.', ), args: (opts.Type as z.ZodType).describe( 'TypeDef of the function\'s parameter object. The PROPS of this obj ARE the function\'s parameters — ' + @@ -103,9 +103,9 @@ const createNewFn = ai.tool({ // Parse the engineer-supplied signature into runtime Types. When // the engineer declared `types` aliases, args/returns may // reference them — we resolve those by parsing through a synthetic - // FnType TypeDef (which routes through `decodeCall`'s alias - // inliner). This makes the engineer's declared aliases live for - // both the targetFn pass-through AND the eventual saved fn. + // FnType TypeDef. `decodeCall` builds a LocalScope binding each + // alias sequentially, so bare `{name: ""}` references + // inside args/returns resolve via AliasType through that scope. let argsType: ObjType; let returnsType: Type; try { @@ -144,6 +144,49 @@ const createNewFn = ai.tool({ ? '(no parameters — body should produce a value of the return type with no inputs)' : paramNames.map((p) => `\`${p}\``).join(', '); + // Build the chain ancestry for the inner programmer. Each prior + // engineer call appended its `create_new_fn` input as one entry. + // The current call appends itself BEFORE the inner programmer is + // launched so the deepest entry is "you are here". + const parentChain = ctx.programmerChain ?? []; + const youAreHere: ProgrammerChainEntry = { + name: input.name, + argsCode, + returnsCode, + description: input.description, + }; + const innerChain: ProgrammerChainEntry[] = [...parentChain, youAreHere]; + + // Render the chain block — only when there's an enclosing caller + // (depth ≥ 1). At depth 0 there is no parent fn; the user's + // request is already in the conversation as a regular message. + const chainBlock = (() => { + if (parentChain.length === 0 && !ctx.originalRequest) return ''; + const lines: string[] = []; + lines.push(`## Call chain — why this function exists`); + lines.push(``); + if (ctx.originalRequest) { + lines.push(`Top-level user request:`); + for (const ln of ctx.originalRequest.split('\n')) lines.push(`> ${ln}`); + lines.push(``); + } + lines.push(`Your function is being built to support a chain of callers:`); + lines.push(``); + innerChain.forEach((entry, i) => { + const here = i === innerChain.length - 1 ? ' ← YOU ARE HERE' : ''; + lines.push(` ${i + 1}. ${entry.name}(${entry.argsCode}): ${entry.returnsCode}${here}`); + lines.push(` "${entry.description}"`); + }); + lines.push(``); + lines.push( + innerChain.length === 1 + ? `Keep your scope tight to what the top-level request actually needs — don't add features it doesn't ask for.` + : `The level-${innerChain.length - 1} caller (\`${parentChain[parentChain.length - 1]!.name}\`) is building its function and needs yours to do its job. Keep your scope tight to what that caller actually needs — don't add features the chain above doesn't ask for.`, + ); + lines.push(``); + return lines.join('\n') + '\n'; + })(); + // Spell out the job in the recursive programmer's first user // message so it has the full signature in scope and doesn't try to // delegate back to find_or_create_functions / create_new_fn. @@ -155,6 +198,7 @@ const createNewFn = ai.tool({ `Returns type: ${returnsCode}`, `Description: ${input.description}`, ``, + chainBlock, `## How parameters work`, ``, `Parameters are bound under a single \`args\` scope variable (the entire signature obj). To read a parameter, walk the path \`args.\`.`, @@ -194,6 +238,7 @@ const createNewFn = ai.tool({ ...ctx, messages, programmerDepth: childDepth, + programmerChain: innerChain, runState: innerRunState, targetFn: { name: input.name, diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index 1d15e6c..71a8149 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -18,7 +18,7 @@ import { ask } from '../tools/ask'; * Works by introspecting the canonical's own `generic` map — each class's * `from` method already knows which parameter names it expects. If the * canonical has any generic slot that's an unnamed/any type, swap it for - * a GenericType placeholder at the same key. + * an AliasType placeholder (bare-name TypeDef `{name: 'V'}`) at the same key. */ function placeholderize(r: Registry, cls: { NAME: string; from: (def: TypeDef, r: Registry) => Type }): Type | undefined { let canonical: Type; @@ -32,7 +32,9 @@ function placeholderize(r: Registry, cls: { NAME: string; from: (def: TypeDef, r const genericDef: Record = {}; for (const k of keys) { - genericDef[k] = { name: 'generic', options: { name: k } }; + // Bare-name shape: `{name: 'V'}` parses to an AliasType('V'), which + // is the unified placeholder/ref runtime form. + genericDef[k] = { name: k }; } try { return cls.from({ name: cls.NAME, generic: genericDef } as TypeDef, r); @@ -75,6 +77,166 @@ function buildTypeDocs(r: Registry): string { return docs.join('\n\n'); } +const EXPR_KINDS = `## Expression kinds — quick reference + +A gin program is a tree of \`ExprDef\` JSON objects. Every node has +\`kind: "..."\` and the fields its kind declares. Twelve kinds in total: + +### \`new\` — construct a value of a given type +\`{ kind: "new", type: , value?: }\` +\`\`\`json +// new num{value: 42} +{ "kind": "new", "type": { "name": "num" }, "value": 42 } + +// new list{values: [1,2,3]} — composite slots are Exprs +{ "kind": "new", "type": { "name": "list", "generic": { "V": { "name": "num" } } }, + "value": [ + { "kind": "new", "type": { "name": "num" }, "value": 1 }, + { "kind": "new", "type": { "name": "num" }, "value": 2 } + ] } + +// new obj{ x: text, y: num } { ... } +{ "kind": "new", "type": { "name": "obj", "props": { "x": {"type":{"name":"text"}}, "y": {"type":{"name":"num"}} } }, + "value": { + "x": { "kind": "new", "type": { "name": "text" }, "value": "hi" }, + "y": { "kind": "new", "type": { "name": "num" }, "value": 1 } + } } +\`\`\` + +### \`get\` — read through a path (variables, props, indexed, calls) +\`{ kind: "get", path: [, , ...] }\` +See the path-system section below for the full step grammar. First step +is always \`{prop:""}\`. + +### \`set\` — write through a path; returns \`bool\` (true=wrote, false=safe-nav abort) +\`{ kind: "set", path: [, ...], value: }\` +\`\`\`json +// counter = counter + 1 +{ "kind": "set", "path": [{"prop":"counter"}], + "value": { "kind": "get", "path": [ + { "prop": "counter" }, { "prop": "add" }, + { "args": { "other": { "kind": "new", "type": { "name": "num" }, "value": 1 } } } + ] } } +\`\`\` + +### \`define\` — bind locals into a child scope, then evaluate \`body\` +\`{ kind: "define", vars: [{ name, type?, value }, ...], body: }\` +Each var is added to scope BEFORE the next var's value is evaluated, so +later vars can reference earlier ones. \`type\` is optional (inferred +from \`value\`'s type). +\`\`\`json +{ "kind": "define", + "vars": [ + { "name": "x", "value": { "kind": "new", "type": { "name": "num" }, "value": 10 } }, + { "name": "y", "value": { "kind": "get", "path": [ + { "prop": "x" }, { "prop": "mul" }, + { "args": { "other": { "kind": "new", "type": { "name": "num" }, "value": 2 } } } + ] } } + ], + "body": { "kind": "get", "path": [{ "prop": "y" }] } +} +\`\`\` + +### \`block\` — sequence of expressions; result is the LAST line's value +\`{ kind: "block", lines: [, , ...] }\` +Earlier lines run for side effects (\`set\`, fns.fetch, etc.). Empty +block returns \`void\`. + +### \`if\` — conditional branching; result is the winning branch's body +\`{ kind: "if", ifs: [{ condition, body }, ...], else?: }\` +First branch whose \`condition\` evaluates true wins. Conditions must be +\`bool\`-typed. \`else\` (optional) handles the no-match case. +\`\`\`json +{ "kind": "if", + "ifs": [{ + "condition": { "kind": "get", "path": [ + { "prop": "x" }, { "prop": "gt" }, + { "args": { "other": { "kind": "new", "type": { "name": "num" }, "value": 0 } } } + ] }, + "body": { "kind": "new", "type": { "name": "text" }, "value": "positive" } + }], + "else": { "kind": "new", "type": { "name": "text" }, "value": "non-positive" } +} +\`\`\` + +### \`switch\` — value-based branching (multi-equals per case) +\`{ kind: "switch", value: , cases: [{ equals: [...], body }], else?: }\` +The case wins if \`value\` equals ANY one of \`equals\`. Use over \`if\` +when comparing one expression against several literal values. + +### \`loop\` — iterate any iterable (list / map / num / text / bool while-loop) +\`{ kind: "loop", over: , body: , key?: string, value?: string, parallel?: {...} }\` +- iterable \`over\` (list/map/num/text): walked once; \`key\` is the index + / map key, \`value\` is the element. Both bind to scope under those + names (override via the optional \`key\`/\`value\` fields). +- bool \`over\`: while-loop semantics. The expression is RE-EVALUATED + each iteration; loop continues while \`true\`, exits the moment it + becomes \`false\`. Use \`set\` exprs in the body to evolve state the + bool reads. Combine with \`flow:break\`/\`flow:continue\` for explicit + early exit. +- \`parallel\`: optional concurrency hints (\`concurrent: num\`, + \`rate: num\` per-second). +\`\`\`json +// for each task in tasks: do something +{ "kind": "loop", + "over": { "kind": "get", "path": [{ "prop": "tasks" }] }, + "body": { "kind": "get", "path": [ + { "prop": "value" }, { "prop": "title" }, { "prop": "add" }, + { "args": { "other": { "kind": "new", "type": { "name": "text" }, "value": "!" } } } + ] } +} +\`\`\` + +### \`lambda\` — callable closure over the lexical scope +\`{ kind: "lambda", type: , body: , constraint?: }\` +Inside the body, \`args\` is the call's arguments obj and \`recurse\` is +this same lambda (for self-calls). Optional \`constraint\` runs before +the body each call (must return \`bool\`); throws on false. +\`\`\`json +// (args: { value: num }) => args.value + 1 +{ "kind": "lambda", + "type": { "name": "function", + "call": { "args": { "name": "obj", "props": { "value": { "type": { "name": "num" } } } }, + "returns": { "name": "num" } } }, + "body": { "kind": "get", "path": [ + { "prop": "args" }, { "prop": "value" }, { "prop": "add" }, + { "args": { "other": { "kind": "new", "type": { "name": "num" }, "value": 1 } } } + ] } +} +\`\`\` + +### \`template\` — string interpolation with \`{name}\` placeholders +\`{ kind: "template", template: "", params: }\` +Each \`{name}\` in the string is replaced by the stringified +\`params.name\`. +\`\`\`json +{ "kind": "template", + "template": "Hello, {who}! You have {n} messages.", + "params": { "kind": "new", + "type": { "name": "obj", "props": { "who": { "type": { "name": "text" } }, "n": { "type": { "name": "num" } } } }, + "value": { + "who": { "kind": "new", "type": { "name": "text" }, "value": "world" }, + "n": { "kind": "new", "type": { "name": "num" }, "value": 3 } + } + } +} +\`\`\` + +### \`flow\` — non-local control: \`break\`, \`continue\`, \`return\`, \`exit\`, \`throw\` +\`{ kind: "flow", action: "break" | "continue" | "return" | "exit" | "throw", value?: , error?: }\` +- \`break\` / \`continue\` — only valid inside a \`loop\`. +- \`return\` — unwinds to the enclosing lambda; \`value\` becomes its result. +- \`exit\` — unwinds all the way to \`engine.run\`; \`value\` becomes the program result. +- \`throw\` — raises \`error\`; caught by a path step's \`catch:\` handler. + +### \`native\` — escape hatch calling a registered native impl by id +\`{ kind: "native", id: "", type?: }\` +You should NOT generate \`native\` directly. Methods on built-in types +(list.push, num.add, etc.) are reached via \`get\` paths — gin resolves +to natives internally. \`native\` is mentioned for completeness only. + +`; + const PATH_EXPLANATION = `## The path system — how to build \`get\` / \`set\` expressions A \`get\` expression walks a path starting from a scope variable. Each step @@ -182,13 +344,48 @@ names, index signatures, and call signatures exist on each type: {{typeDocs}} \`\`\` +${EXPR_KINDS} ${PATH_EXPLANATION} ## Globals always available - \`fns.fetch({ url, method?, headers?, body?, output?: typ }): R\` — HTTP fetch. - \`fns.llm({ prompt, tools?, output?: typ }): R\` — LLM call. +- \`fns.log({ message: any }): void\` — print a runtime message to the user (stderr). Use for progress narration, intermediate values, debug breadcrumbs. Distinct from the program's return value. +- \`fns.ask({ title: text, details: text, output?: typ }): optional\` — pause execution and prompt the user. With \`output\` set the consumer walks the user through any complex shape (obj fields, list items, choices, optionals). Returns \`null\` (\`optional\`) on cancel — handle that explicitly. - \`vars.*\` — named typed values, persisted on disk. +## Writing prompt-friendly types for \`fns.ask\` + +The ask consumer uses each (sub)type's \`docs\` field as the user-facing +label for its prompt. Put short, human-readable descriptions on every +field of an output type you pass to \`fns.ask\` — that's what the user +sees, not the field name or the raw TypeDef. + +\`\`\`json +// Asking for a list of contacts: +{ "kind": "get", "path": [ + { "prop": "fns" }, { "prop": "ask" }, + { "args": { + "title": { "kind": "new", "type": { "name": "text" }, "value": "Add contacts" }, + "details": { "kind": "new", "type": { "name": "text" }, "value": "Enter each contact one at a time. Press Enter on the 'add another?' prompt to stop." }, + "output": { "kind": "new", "type": { "name": "typ" }, + "value": { "name": "list", "generic": { "V": { + "name": "obj", + "props": { + "name": { "type": { "name": "text" }, "docs": "Full name" }, + "email": { "type": { "name": "text", "options": { "pattern": ".+@.+" } }, "docs": "Email address" }, + "role": { "type": { "name": "enum", "options": { "values": { "admin": "admin", "viewer": "viewer" } } }, "docs": "Permission role" } + } + } } } } + } } +]} +\`\`\` + +The user sees three labelled prompts per item — "Full name", "Email +address", "Permission role" (as a 1/2 choice) — instead of \`name\`, +\`email\`, \`role\`. Always set \`docs\` on each obj field; for list +elements, \`docs\` on the element type itself works too. + ## Typed output — why it matters When you pass \`output\` as a gin TypeDef, the returned value IS that diff --git a/packages/ginny/src/tools/test.ts b/packages/ginny/src/tools/test.ts index a0899bf..8c706e4 100644 --- a/packages/ginny/src/tools/test.ts +++ b/packages/ginny/src/tools/test.ts @@ -3,6 +3,7 @@ import { ToolInterrupt } from '@aeye/core'; import { LambdaExpr, val, type Value, type ObjType, type Registry } from '@aeye/gin'; import { ai } from '../ai'; import { flushDirtyVars } from '../vars-global'; +import { withAskHandler } from '../natives/ask'; /** * Build the Zod sub-schema the model sees for `args`. @@ -67,9 +68,11 @@ export const test = ai.tool({ } try { - const value = ctx.targetFn - ? await invokeAsLambda(ctx.registry, ctx.engine, ctx.targetFn, draft, input.args) - : await invokeTopLevel(ctx.registry, ctx.engine, draft, input.args); + const value = await withAskHandler(ctx.ask, ctx.signal, () => + ctx.targetFn + ? invokeAsLambda(ctx.registry, ctx.engine, ctx.targetFn, draft, input.args) + : invokeTopLevel(ctx.registry, ctx.engine, draft, input.args), + ); const rawResult = value.type?.encode ? value.type.encode(value.raw) : value.raw; if (input.expectError) { From bf65bc789f936bf7c80403acea084732e0d4e2e8 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Thu, 30 Apr 2026 21:52:53 -0400 Subject: [PATCH 05/21] Limit validation error size; strict schema fixes Add truncation for oversized validation messages and improve strict-mode schema handling. - Introduce truncateValidationError (default cap 4096) and validationErrorMaxLength on PromptInput; apply truncation to JSON parse, schema parse, validation errors, and tool-arg parse paths. Pass the option into newToolExecution so tool-related errors are also bounded. - Replace fragile z.codec usage for ZodRecord with z.preprocess to robustly normalize array-of-{key,value} inputs into records. Add a preprocess for ZodTuple to accept object-with-numeric-keys and convert to arrays before validation. - In convertSchema, emit strict-mode tuples as objects with numeric-string keys to preserve per-position types (with fallbacks for rest/variadic tuples). - Update many tests and fixtures to use the updated 'obj' extension name (replacing previous 'object' references) and include various ginny prompt/tool updates (new/modified ginny tool files and prompt changes). These changes avoid massive zod error payloads that consume model context and make strict-mode conversions more robust for recursive and union schemas. --- packages/core/src/prompt.ts | 50 ++++- packages/core/src/schema.ts | 109 +++++++++-- .../src/__tests__/build-schemas-named.test.ts | 12 +- .../src/__tests__/call-type-aliases.test.ts | 18 +- packages/gin/src/__tests__/color.test.ts | 2 +- .../src/__tests__/composite-values.test.ts | 2 +- .../gin/src/__tests__/constraints.test.ts | 2 +- packages/gin/src/__tests__/deep-set.test.ts | 10 +- packages/gin/src/__tests__/duration.test.ts | 2 +- .../gin/src/__tests__/expr-validate.test.ts | 8 +- .../__tests__/exprs-lambda-template.test.ts | 8 +- .../src/__tests__/extension-generics.test.ts | 12 +- packages/gin/src/__tests__/extension.test.ts | 8 +- packages/gin/src/__tests__/fn.test.ts | 4 +- .../gin/src/__tests__/gaps-analysis.test.ts | 4 +- .../gin/src/__tests__/gaps-satisfies.test.ts | 4 +- packages/gin/src/__tests__/iface.test.ts | 4 +- .../src/__tests__/natives-collections.test.ts | 6 +- packages/gin/src/__tests__/person.test.ts | 4 +- packages/gin/src/__tests__/readme.test.ts | 2 +- packages/gin/src/__tests__/recurse.test.ts | 8 +- .../gin/src/__tests__/recursive-types.test.ts | 14 +- .../gin/src/__tests__/scopes-typedef.test.ts | 8 +- .../gin/src/__tests__/super-override.test.ts | 4 +- packages/gin/src/__tests__/toCode.test.ts | 8 +- packages/gin/src/__tests__/toSchema.test.ts | 4 +- .../gin/src/__tests__/validate-set.test.ts | 2 +- packages/gin/src/expr.ts | 17 +- packages/gin/src/exprs/block.ts | 27 ++- packages/gin/src/exprs/code.ts | 39 ++-- packages/gin/src/exprs/define.ts | 12 +- packages/gin/src/exprs/flow.ts | 9 +- packages/gin/src/exprs/get.ts | 2 +- packages/gin/src/exprs/if.ts | 13 +- packages/gin/src/exprs/lambda.ts | 11 +- packages/gin/src/exprs/loop.ts | 26 ++- packages/gin/src/exprs/new.ts | 72 +++++++- packages/gin/src/exprs/set.ts | 4 +- packages/gin/src/exprs/switch.ts | 17 +- packages/gin/src/exprs/template.ts | 22 ++- packages/gin/src/extension.ts | 10 +- packages/gin/src/node.ts | 10 + packages/gin/src/path.ts | 26 ++- packages/gin/src/type.ts | 172 ++++++++++++++---- packages/gin/src/types/alias.ts | 7 +- packages/gin/src/types/and.ts | 6 +- packages/gin/src/types/any.ts | 5 +- packages/gin/src/types/bool.ts | 5 +- packages/gin/src/types/color.ts | 5 +- packages/gin/src/types/date.ts | 5 +- packages/gin/src/types/duration.ts | 5 +- packages/gin/src/types/enum.ts | 9 +- packages/gin/src/types/fn.ts | 11 +- packages/gin/src/types/iface.ts | 21 ++- packages/gin/src/types/list.ts | 11 +- packages/gin/src/types/literal.ts | 7 +- packages/gin/src/types/map.ts | 7 +- packages/gin/src/types/not.ts | 5 +- packages/gin/src/types/null.ts | 5 +- packages/gin/src/types/nullable.ts | 7 +- packages/gin/src/types/num.ts | 16 +- packages/gin/src/types/obj.ts | 20 +- packages/gin/src/types/optional.ts | 7 +- packages/gin/src/types/or.ts | 7 +- packages/gin/src/types/text.ts | 5 +- packages/gin/src/types/timestamp.ts | 5 +- packages/gin/src/types/tuple.ts | 7 +- packages/gin/src/types/typ.ts | 7 +- packages/gin/src/types/void.ts | 5 +- packages/ginny/README.md | 4 +- packages/ginny/src/config.ts | 3 + packages/ginny/src/context.ts | 22 +-- packages/ginny/src/event-display.ts | 27 ++- packages/ginny/src/index.ts | 25 ++- packages/ginny/src/model-selection.ts | 16 +- packages/ginny/src/progress.ts | 18 +- packages/ginny/src/prompts/architect.ts | 4 +- packages/ginny/src/prompts/dba.ts | 4 +- .../src/prompts/{engineer.ts => designer.ts} | 93 +++++----- packages/ginny/src/prompts/programmer.ts | 97 +++++++++- packages/ginny/src/prompts/researcher.ts | 4 +- .../ginny/src/tools/find-or-create-fns.ts | 35 ++-- packages/ginny/src/tools/finish.ts | 8 +- packages/ginny/src/tools/print-fn.ts | 87 +++++++++ packages/ginny/src/tools/search-fns.ts | 37 ++++ packages/ginny/src/tools/search-vars.ts | 33 ++++ packages/ginny/src/tools/test.ts | 4 +- packages/ginny/src/tools/write.ts | 19 +- 88 files changed, 1121 insertions(+), 427 deletions(-) rename packages/ginny/src/prompts/{engineer.ts => designer.ts} (84%) create mode 100644 packages/ginny/src/tools/print-fn.ts create mode 100644 packages/ginny/src/tools/search-fns.ts create mode 100644 packages/ginny/src/tools/search-vars.ts diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index 8fa2b4d..e215f5c 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -6,6 +6,24 @@ import { AnyTool, Tool, ToolCompatible, ToolInterrupt, PromptSuspend } from "./t import { Component, Context, Events, Executor, FinishReason, Message, Names, OptionalParams, Reasoning, Request, RequiredKeys, ResponseFormat, Streamer, ToolCall, ToolDefinition, Tuple, Usage } from "./types"; import { strictify, toJSONSchema } from "./schema"; +/** Default cap (chars) for validation error messages we surface to the LLM. */ +const DEFAULT_VALIDATION_ERROR_MAX_LENGTH = 4096; + +/** + * Truncate a validation error so the corrective user message we send back + * to the LLM stays bounded. Lengthy zod errors against deep recursive + * schemas can run 100k+ characters — eating context and burning tokens + * on noise the model can't usefully act on. Anything past `max` is + * replaced with a `… (N more characters)` marker so the LLM both knows + * the message was clipped and roughly how much was lost. + */ +function truncateValidationError(message: string, max?: number): string { + const cap = max ?? DEFAULT_VALIDATION_ERROR_MAX_LENGTH; + if (cap <= 0 || message.length <= cap) return message; + const dropped = message.length - cap; + return `${message.slice(0, cap)}… (${dropped} more characters)`; +} + /** * Represents a tool that can be selected by the retool function. * Can be either a tool name (string) to select from predefined tools, @@ -106,6 +124,13 @@ export interface PromptInput< toolExecution?: 'sequential' | 'parallel' | 'immediate'; // Number of attempts to retry tool calls upon failure. Defaults to 2. */ toolRetries?: number; + // Maximum number of characters of any validation error message + // (Zod tool-arg parse, output schema parse, output validate, JSON parse) + // surfaced back to the LLM as a corrective user message. Lengthy zod + // errors against deep recursive schemas can otherwise blow past 100k + // characters, blowing the model's context and the user's terminal both. + // Truncation appends a `… (N more characters)` marker. Default 4096. + validationErrorMaxLength?: number; // Number of attempts to get the output in the right format and to pass validation. Defaults to what's on the context, which defaults to 2. outputRetries?: number; // Number of attempts that will be made to forget context messages of the past in order to complete the request. Defaults to what's on the context, which defaults to 1. @@ -673,7 +698,7 @@ export class Prompt< // Handle tool calls if (chunk.toolCallNamed) { - const toolExecutor = newToolExecution(ctx, chunk.toolCallNamed, toolMap.get(chunk.toolCallNamed.name)); + const toolExecutor = newToolExecution(ctx, chunk.toolCallNamed, toolMap.get(chunk.toolCallNamed.name), this.input.validationErrorMaxLength); toolExecutors.push(toolExecutor); toolExecutorMap.set(chunk.toolCallNamed.id, toolExecutor); @@ -960,6 +985,7 @@ export class Prompt< let errorMessage = ''; let resetReason = ''; + const errMax = this.input.validationErrorMaxLength; try { const parsedJSON = JSON.parse(potentialJSON); @@ -968,7 +994,10 @@ export class Prompt< const issueSummary = parsedSafe.error.issues .map(i => `- ${i.path.join('.')}: ${i.message}${['string', 'boolean', 'number'].includes(typeof i.input) ? ` (input: ${i.input})` : ''}`) .join('\n') - errorMessage = `The output was an invalid format:\n${issueSummary}\n\nPlease adhere to the output schema:\n${toJSONSchema(schema, this.input.strict ?? true)}`; + errorMessage = truncateValidationError( + `The output was an invalid format:\n${issueSummary}\n\nPlease adhere to the output schema:\n${toJSONSchema(schema, this.input.strict ?? true)}`, + errMax, + ); resetReason = 'schema-parsing'; } else { result = parsedSafe.data as unknown as TOutput; @@ -976,12 +1005,18 @@ export class Prompt< try { await this.input.validate?.(result, ctx); } catch (validationError: any) { - errorMessage = `The output failed validation:\n${validationError.message}`; + errorMessage = truncateValidationError( + `The output failed validation:\n${validationError.message}`, + errMax, + ); resetReason = 'validation'; } } } catch (parseError: any) { - errorMessage = `The output was not valid JSON:\n${parseError.message}`; + errorMessage = truncateValidationError( + `The output was not valid JSON:\n${parseError.message}`, + errMax, + ); resetReason = 'json-parsing'; } @@ -1413,7 +1448,7 @@ function emitter() { return emitter; } -function newToolExecution(ctx: Context, toolCall: ToolCall, toolInfo?: { tool: T, definition: ToolDefinition }) { +function newToolExecution(ctx: Context, toolCall: ToolCall, toolInfo?: { tool: T, definition: ToolDefinition }, validationErrorMaxLength?: number) { const start = emitter(); const output = emitter(); const error = emitter(); @@ -1447,7 +1482,10 @@ function newToolExecution(ctx: Context, toolCall: T start.ready = true; } catch (e: any) { execution.status = 'invalid'; - execution.error = `Error parsing tool arguments: ${e.message}, args: ${args}`; + execution.error = truncateValidationError( + `Error parsing tool arguments: ${e.message}, args: ${args}`, + validationErrorMaxLength, + ); error.ready = true; } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index f20e6cb..56e3f84 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -156,30 +156,39 @@ function strictifySimple( } // Handle ZodRecord + // Strict-mode JSON Schema represents records as `array of {key, value}` — + // OpenAI's structured outputs has no native pattern for open records, so + // we rewrite. Previously this used `z.codec(arrayIn, recordOut, decode)`, + // but z.codec proved fragile inside recursive lazy unions: the inner + // codec's transform doesn't always run when validation traverses a + // sibling branch, leaving the data in array form when later code expects + // record form (and vice-versa) — surfacing as "expected array, received + // object" union failures. + // + // `z.preprocess` is more robust here: it normalizes the input to record + // shape BEFORE the record schema sees it. Either array-of-pairs (from a + // strict-mode model) or already-a-record (when the schema is reused + // outside strict context) is accepted; both arrive at the record schema + // as a record. if (schema instanceof z.ZodRecord) { const keyTransformed = schema.keyType ? transform(schema.keyType) as z.ZodType : z.string(); const valueTransformed = transform(schema.valueType); - // For the input schema (array of {key, value}), use the input side of any codecs - const key = getInputSchema(keyTransformed); - const value = getInputSchema(valueTransformed); - return transferMetadata( - z.codec( - z.array(z.object({ key, value })), - z.record(keyTransformed, valueTransformed), - { - decode: (arr) => { + z.preprocess( + (val) => { + if (Array.isArray(val)) { const record: Record = {}; - for (const { key, value } of arr) { - record[key as PropertyKey] = value; + for (const entry of val) { + if (entry && typeof entry === 'object' && 'key' in entry && 'value' in entry) { + record[(entry as { key: PropertyKey }).key] = (entry as { value: unknown }).value; + } } return record; - }, - encode: (rec) => Object.entries(rec).map( - ([key, value]) => ({ key, value }) - ), - } + } + return val; + }, + z.record(keyTransformed, valueTransformed), ), schema ); @@ -210,10 +219,38 @@ function strictifySimple( } // Handle ZodTuple + // Strict-mode JSON Schema represents tuples as an object with numeric + // string keys (`{"0": , "1": , ...}`) — OpenAI's structured + // outputs has no positional `prefixItems` support and would otherwise + // collapse a `[string, number, bool]` to an array of `(string|number|bool)`, + // losing the per-position type. Encoding as an object preserves it. + // The strictified schema accepts EITHER form: an object with "0".."n-1" + // keys (what a strict-mode model produces) or an array (what a callsite + // outside strict context would pass). Both arrive at the tuple schema as + // an array. if (schema instanceof z.ZodTuple) { + const items = schema.def.items.map(transform) as [z.ZodType, ...z.ZodType[]]; + const rest = schema.def.rest ? transform(schema.def.rest) : undefined; + const tupleSchema = rest ? z.tuple(items, rest) : z.tuple(items); return transferMetadata( - z.tuple(schema.def.items.map(transform) as [z.ZodType, ...z.ZodType[]]), - schema + z.preprocess( + (val) => { + if (val && typeof val === 'object' && !Array.isArray(val)) { + const obj = val as Record; + const keys = Object.keys(obj); + if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) { + const indices = keys.map((k) => parseInt(k, 10)); + const len = Math.max(...indices) + 1; + const arr: unknown[] = new Array(len); + for (const k of keys) arr[parseInt(k, 10)] = obj[k]; + return arr; + } + } + return val; + }, + tupleSchema, + ), + schema, ); } @@ -610,13 +647,45 @@ function convertSchema(schema: z.ZodType | z.core.$ZodType, context: ConversionC // Handle ZodTuple if (schema instanceof z.ZodTuple) { - // Tuples in JSON Schema can be represented as arrays with prefixItems - // For simplicity, we'll use an array type const items = schema.def.items.map(item => convert(item, context)); const rest = schema.def.rest ? convert(schema.def.rest, context) : undefined; + + // Strict mode: encode as an object with numeric-string keys ("0", "1", + // ...) so each position carries its exact type. OpenAI's strict + // structured outputs has no positional `prefixItems` support and would + // otherwise force every position into a single shared `items` (anyOf + // collapse), erasing per-position types. The matching strictify + // preprocess converts the object back to an array before tuple + // validation. Variadic rests aren't representable in strict-object + // form (no fixed key count) — fall back to a homogeneous array of + // (items ∪ rest) for that rare case. + if (context.strict && !rest) { + // OpenAI strict mode requires every property to be in `required`. + // Optional positions are surfaced via nullable on their schema (the + // same convention the ZodObject handler uses above) — the matching + // strictify preprocess unwraps `null` back to the array form before + // tuple validation. additionalProperties: false locks the shape. + const properties: Record = {}; + const required: string[] = []; + for (let i = 0; i < items.length; i++) { + const k = String(i); + properties[k] = isOptional(schema.def.items[i]) ? makeNullable(items[i]!) : items[i]!; + required.push(k); + } + return { + type: 'object', + properties, + required, + additionalProperties: false, + }; + } + const result: JSONSchema = { type: 'array' }; if (context.strict && rest) { + // Strict + rest fallback: every position fits one of the declared + // types (positional info is lost — there's no way to express a + // mixed-prefix-plus-rest array in OpenAI strict mode). items.push(rest); } diff --git a/packages/gin/src/__tests__/build-schemas-named.test.ts b/packages/gin/src/__tests__/build-schemas-named.test.ts index f9f45fb..f611984 100644 --- a/packages/gin/src/__tests__/build-schemas-named.test.ts +++ b/packages/gin/src/__tests__/build-schemas-named.test.ts @@ -9,7 +9,7 @@ import { createRegistry, buildSchemas } from '../index'; describe('buildSchemas: named user types', () => { test('registered Extension shows up in opts.Type', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', props: { title: { type: r.text({ minLength: 1 }) }, @@ -32,14 +32,14 @@ describe('buildSchemas: named user types', () => { const r = createRegistry(); // Register A with one shape. - const A1 = r.extend('object', { + const A1 = r.extend('obj', { name: 'A', props: { x: { type: r.num() } }, }); r.register(A1); // Pass a DIFFERENT A via opts.types — should win the dedup. - const A2 = r.extend('object', { + const A2 = r.extend('obj', { name: 'A', props: { y: { type: r.text() } }, }); @@ -57,7 +57,7 @@ describe('buildSchemas: named user types', () => { test('user-defined type is usable from an LLM-style JSON type reference', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', docs: 'a work item', props: { @@ -86,7 +86,7 @@ describe('buildSchemas: named user types', () => { test('unregistered extension is invisible to opts.Type', () => { const r = createRegistry(); // An Extension that's never registered — no branch for it. - r.extend('object', { + r.extend('obj', { name: 'OrphanTask', props: { title: { type: r.text() } }, }); @@ -95,7 +95,7 @@ describe('buildSchemas: named user types', () => { // (`num`, `object`, etc.), so ad-hoc extensions are not representable. expect(opts.Type.safeParse({ name: 'OrphanTask' }).success).toBe(false); // Register the same structure → now accepted. - const sibling = r.extend('object', { + const sibling = r.extend('obj', { name: 'RegisteredTask', props: { title: { type: r.text() } }, }); diff --git a/packages/gin/src/__tests__/call-type-aliases.test.ts b/packages/gin/src/__tests__/call-type-aliases.test.ts index 989ea9a..cd4f9b3 100644 --- a/packages/gin/src/__tests__/call-type-aliases.test.ts +++ b/packages/gin/src/__tests__/call-type-aliases.test.ts @@ -22,7 +22,7 @@ describe('CallDef.types — basic resolution', () => { name: 'function', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object', props: { a: { type: { name: 'counter' } }, b: { type: { name: 'counter' } } } }, + args: { name: 'obj', props: { a: { type: { name: 'counter' } }, b: { type: { name: 'counter' } } } }, returns: { name: 'counter' }, }, }); @@ -46,7 +46,7 @@ describe('CallDef.types — basic resolution', () => { A: { name: 'num', options: { whole: true, min: 1 } }, B: { name: 'list', generic: { V: { name: 'A' } } }, }, - args: { name: 'object', props: { items: { type: { name: 'B' } } } }, + args: { name: 'obj', props: { items: { type: { name: 'B' } } } }, returns: { name: 'A' }, }, }); @@ -69,7 +69,7 @@ describe('CallDef.types — basic resolution', () => { types: { valueList: { name: 'list', generic: { V: { name: 'T' } } }, }, - args: { name: 'object', props: { items: { type: { name: 'valueList' } } } }, + args: { name: 'obj', props: { items: { type: { name: 'valueList' } } } }, returns: { name: 'T' }, }, }); @@ -91,7 +91,7 @@ describe('CallDef.types — round-trip', () => { name: 'function', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object', props: { a: { type: { name: 'counter' } } } }, + args: { name: 'obj', props: { a: { type: { name: 'counter' } } } }, returns: { name: 'counter' }, }, }; @@ -100,7 +100,7 @@ describe('CallDef.types — round-trip', () => { expect(json.call?.types).toBeDefined(); expect(json.call?.types?.['counter']).toEqual({ name: 'num', options: { whole: true, min: 1 } }); // The args slot still references the alias by NAME (bare form). - expect(json.call?.args).toEqual({ name: 'object', props: { a: { type: { name: 'counter' } } } }); + expect(json.call?.args).toEqual({ name: 'obj', props: { a: { type: { name: 'counter' } } } }); expect(json.call?.returns).toEqual({ name: 'counter' }); }); @@ -112,7 +112,7 @@ describe('CallDef.types — round-trip', () => { A: { name: 'num', options: { min: 0 } }, B: { name: 'list', generic: { V: { name: 'A' } } }, }, - args: { name: 'object', props: { xs: { type: { name: 'B' } } } }, + args: { name: 'obj', props: { xs: { type: { name: 'B' } } } }, returns: { name: 'A' }, }, }; @@ -131,7 +131,7 @@ describe('CallDef.types — round-trip', () => { generic: { T: { name: 'T' } }, call: { types: { box: { name: 'list', generic: { V: { name: 'T' } } } }, - args: { name: 'object', props: { v: { type: { name: 'box' } } } }, + args: { name: 'obj', props: { v: { type: { name: 'box' } } } }, returns: { name: 'T' }, }, }); @@ -152,7 +152,7 @@ describe('CallDef.types — ExprDef bodies', () => { name: 'function', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object' }, + args: { name: 'obj' }, returns: { name: 'counter' }, get: { kind: 'new', type: { name: 'counter' }, value: 7 }, }, @@ -172,7 +172,7 @@ describe('CallDef.types — toCodeDefinition rendering', () => { name: 'function', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, - args: { name: 'object', props: { n: { type: { name: 'counter' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'counter' } } } }, returns: { name: 'counter' }, }, }); diff --git a/packages/gin/src/__tests__/color.test.ts b/packages/gin/src/__tests__/color.test.ts index c84d000..b808d56 100644 --- a/packages/gin/src/__tests__/color.test.ts +++ b/packages/gin/src/__tests__/color.test.ts @@ -33,7 +33,7 @@ describe('ColorType', () => { test('init spec exposes r/g/b/a args', () => { const i = r.color().init(); expect(i).toBeDefined(); - expect(i!.args.name).toBe('object'); + expect(i!.args.name).toBe('obj'); }); test('props include components + manipulation + conversion', () => { diff --git a/packages/gin/src/__tests__/composite-values.test.ts b/packages/gin/src/__tests__/composite-values.test.ts index 1bf4de8..661d19f 100644 --- a/packages/gin/src/__tests__/composite-values.test.ts +++ b/packages/gin/src/__tests__/composite-values.test.ts @@ -35,7 +35,7 @@ describe('composite values preserve actual element types', () => { const r = createRegistry(); const comparable = r.iface({ props: { toText: { type: { name: 'function', call: { - args: { name: 'object' }, returns: { name: 'text' }, + args: { name: 'obj' }, returns: { name: 'text' }, } } } }, }); const box = r.obj({ thing: { type: comparable } }); diff --git a/packages/gin/src/__tests__/constraints.test.ts b/packages/gin/src/__tests__/constraints.test.ts index 816979f..95e077c 100644 --- a/packages/gin/src/__tests__/constraints.test.ts +++ b/packages/gin/src/__tests__/constraints.test.ts @@ -121,7 +121,7 @@ describe('Lambda constraints', () => { type: { name: 'function', call: { - args: { name: 'object', props: { x: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, diff --git a/packages/gin/src/__tests__/deep-set.test.ts b/packages/gin/src/__tests__/deep-set.test.ts index 1d078d5..e206be3 100644 --- a/packages/gin/src/__tests__/deep-set.test.ts +++ b/packages/gin/src/__tests__/deep-set.test.ts @@ -110,7 +110,7 @@ describe('set return value + safe-navigation', () => { const fnType = r.parse({ name: 'function', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'get', @@ -237,7 +237,7 @@ describe('deep set: field → index set', () => { value: { kind: 'new', type: { - name: 'object', + name: 'obj', props: { inner: { type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } } } }, }, value: { inner: [['a', 1]] }, @@ -272,7 +272,7 @@ describe('deep set: method call with CallDef.set', () => { const setterFn = r.parse({ name: 'function', call: { - args: { name: 'object', props: { key: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { key: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'block', @@ -339,7 +339,7 @@ describe('deep set: direct call with CallDef.set', () => { const fnType = r.parse({ name: 'function', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'get', @@ -381,7 +381,7 @@ describe('deep set: direct call with CallDef.set', () => { name: 'fn', value: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }], diff --git a/packages/gin/src/__tests__/duration.test.ts b/packages/gin/src/__tests__/duration.test.ts index 8f4bf0c..5887163 100644 --- a/packages/gin/src/__tests__/duration.test.ts +++ b/packages/gin/src/__tests__/duration.test.ts @@ -29,7 +29,7 @@ describe('DurationType', () => { test('init spec exposes component args', () => { const i = r.duration().init(); expect(i).toBeDefined(); - expect(i!.args.name).toBe('object'); + expect(i!.args.name).toBe('obj'); expect(i!.run).toBeDefined(); }); diff --git a/packages/gin/src/__tests__/expr-validate.test.ts b/packages/gin/src/__tests__/expr-validate.test.ts index 9701f05..d36d0c2 100644 --- a/packages/gin/src/__tests__/expr-validate.test.ts +++ b/packages/gin/src/__tests__/expr-validate.test.ts @@ -63,7 +63,7 @@ describe('LambdaExpr validation', () => { const probs = e.validate({ kind: 'lambda', type: { name: 'function', call: { - args: { name: 'object' }, + args: { name: 'obj' }, returns: { name: 'num' }, } }, body: { kind: 'new', type: { name: 'text' }, value: 'wrong' }, @@ -75,7 +75,7 @@ describe('LambdaExpr validation', () => { const probs = e.validate({ kind: 'lambda', type: { name: 'function', call: { - args: { name: 'object' }, + args: { name: 'obj' }, returns: { name: 'num' }, } }, body: { kind: 'new', type: { name: 'num' }, value: 42 }, @@ -89,7 +89,7 @@ describe('TemplateExpr validation', () => { const probs = e.validate({ kind: 'template', template: { kind: 'new', type: { name: 'num' }, value: 42 }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, }); expect(probs.list.some((p) => p.code === 'template.template.type')).toBe(true); }); @@ -107,7 +107,7 @@ describe('TemplateExpr validation', () => { const probs = e.validate({ kind: 'template', template: { kind: 'new', type: { name: 'text' }, value: 'hi' }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, }); expect(probs.list.some((p) => p.code === 'template.template.type')).toBe(false); expect(probs.list.some((p) => p.code === 'template.params.type')).toBe(false); diff --git a/packages/gin/src/__tests__/exprs-lambda-template.test.ts b/packages/gin/src/__tests__/exprs-lambda-template.test.ts index f480b28..c1df82b 100644 --- a/packages/gin/src/__tests__/exprs-lambda-template.test.ts +++ b/packages/gin/src/__tests__/exprs-lambda-template.test.ts @@ -25,7 +25,7 @@ describe('evalLambda + list.map', () => { type: { name: 'function', call: { - args: { name: 'object', props: { value: { type: { name: 'num' } }, index: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { value: { type: { name: 'num' } }, index: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, @@ -65,7 +65,7 @@ describe('evalLambda + list.map', () => { args: { fn: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'bool' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, body: { kind: 'get', path: [ @@ -93,7 +93,7 @@ describe('evalTemplate', () => { template: { kind: 'new', type: { name: 'text' }, value: 'Hello {name}, you have {count} messages' }, params: { kind: 'new', - type: { name: 'object', props: { name: { type: { name: 'text' } }, count: { type: { name: 'num' } } } }, + type: { name: 'obj', props: { name: { type: { name: 'text' } }, count: { type: { name: 'num' } } } }, value: { name: 'Alice', count: 3 }, }, }); @@ -104,7 +104,7 @@ describe('evalTemplate', () => { const v = await e.run({ kind: 'template', template: { kind: 'new', type: { name: 'text' }, value: 'hi {missing}' }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, }); expect(v.raw).toBe('hi '); }); diff --git a/packages/gin/src/__tests__/extension-generics.test.ts b/packages/gin/src/__tests__/extension-generics.test.ts index 2c81377..5839ae4 100644 --- a/packages/gin/src/__tests__/extension-generics.test.ts +++ b/packages/gin/src/__tests__/extension-generics.test.ts @@ -16,7 +16,7 @@ describe('Extension generics', () => { test('declare: Box placeholders survive as AliasType', () => { const T = r.alias('T'); - const Box = r.extend('object', { + const Box = r.extend('obj', { name: 'Box', generic: { T }, props: { @@ -32,7 +32,7 @@ describe('Extension generics', () => { test('Box resolution: extra-scope T=num makes value.type behave as num', () => { const reg = createRegistry(); const T = reg.alias('T'); - const Box = reg.extend('object', { + const Box = reg.extend('obj', { name: 'Box', generic: { T }, props: { value: { type: T } }, @@ -50,7 +50,7 @@ describe('Extension generics', () => { const reg = createRegistry(); const A = reg.alias('A'); const B = reg.alias('B'); - const Pair = reg.extend('object', { + const Pair = reg.extend('obj', { name: 'Pair', generic: { A, B }, props: { @@ -90,7 +90,7 @@ describe('Extension generics', () => { const reg = createRegistry(); const K = reg.alias('K'); const V = reg.alias('V'); - const Bag = reg.extend('object', { + const Bag = reg.extend('obj', { name: 'Bag', generic: { K, V }, get: { @@ -110,7 +110,7 @@ describe('Extension generics', () => { test('JSON round-trip preserves generic placeholder', () => { const reg = createRegistry(); const T = reg.alias('T'); - const Holder = reg.extend('object', { + const Holder = reg.extend('obj', { name: 'Holder', generic: { T }, props: { item: { type: T } }, @@ -131,7 +131,7 @@ describe('Extension generics', () => { test('extra-scope inside props is visible via props()', () => { const reg = createRegistry(); const T = reg.alias('T'); - const Wrapper = reg.extend('object', { + const Wrapper = reg.extend('obj', { name: 'Wrapper', generic: { T }, props: { inside: { type: T } }, diff --git a/packages/gin/src/__tests__/extension.test.ts b/packages/gin/src/__tests__/extension.test.ts index 2219b56..0762a1d 100644 --- a/packages/gin/src/__tests__/extension.test.ts +++ b/packages/gin/src/__tests__/extension.test.ts @@ -54,7 +54,7 @@ describe('Extension', () => { test('auto-Extension: object.props is native (no wrap)', () => { const r = createRegistry(); const json = { - name: 'object', + name: 'obj', props: { x: { type: { name: 'num' } } }, }; const back = r.parse(json); @@ -65,7 +65,7 @@ describe('Extension', () => { const r = createRegistry(); const json = { name: 'function', - call: { args: { name: 'object' }, returns: { name: 'num' } }, + call: { args: { name: 'obj' }, returns: { name: 'num' } }, }; const back = r.parse(json); expect(back).not.toBeInstanceOf(Extension); @@ -75,9 +75,9 @@ describe('Extension', () => { // obj consumes props but not init; adding init should trigger wrap. const r = createRegistry(); const json = { - name: 'object', + name: 'obj', props: { x: { type: { name: 'num' } } }, - init: { args: { name: 'object' }, run: { kind: 'native', id: 'foo' } }, + init: { args: { name: 'obj' }, run: { kind: 'native', id: 'foo' } }, }; const back = r.parse(json); expect(back).toBeInstanceOf(Extension); diff --git a/packages/gin/src/__tests__/fn.test.ts b/packages/gin/src/__tests__/fn.test.ts index f6ec5a1..6a76274 100644 --- a/packages/gin/src/__tests__/fn.test.ts +++ b/packages/gin/src/__tests__/fn.test.ts @@ -8,7 +8,7 @@ describe('FnType', () => { test('builder with args/returns', () => { const t = r.fn(r.obj({ x: { type: r.num() } }), r.num()) as FnType; expect(t).toBeInstanceOf(FnType); - expect(t.call()?.args.name).toBe('object'); + expect(t.call()?.args.name).toBe('obj'); expect(t.call()?.returns?.name).toBe('num'); }); @@ -42,7 +42,7 @@ describe('FnType', () => { test('call is natively consumed → no auto-Extension', () => { const json = { name: 'function', - call: { args: { name: 'object' }, returns: { name: 'num' } }, + call: { args: { name: 'obj' }, returns: { name: 'num' } }, }; const back = r.parse(json); expect(back).toBeInstanceOf(FnType); diff --git a/packages/gin/src/__tests__/gaps-analysis.test.ts b/packages/gin/src/__tests__/gaps-analysis.test.ts index 540a6ff..09de6df 100644 --- a/packages/gin/src/__tests__/gaps-analysis.test.ts +++ b/packages/gin/src/__tests__/gaps-analysis.test.ts @@ -12,7 +12,7 @@ describe('Engine.typeOf', () => { test('lambda returns the declared fn type', () => { const t = e.typeOf({ kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } }, body: { kind: 'new', type: { name: 'text' }, value: 'hi' }, } as any); expect(t.name).toBe('function'); @@ -77,7 +77,7 @@ describe('Engine.typeOf', () => { const t = e.typeOf({ kind: 'template', template: { kind: 'new', type: { name: 'text' }, value: '' }, - params: { kind: 'new', type: { name: 'object', props: {} }, value: {} }, + params: { kind: 'new', type: { name: 'obj', props: {} }, value: {} }, } as any); expect(t.name).toBe('text'); }); diff --git a/packages/gin/src/__tests__/gaps-satisfies.test.ts b/packages/gin/src/__tests__/gaps-satisfies.test.ts index f2b1084..66b3796 100644 --- a/packages/gin/src/__tests__/gaps-satisfies.test.ts +++ b/packages/gin/src/__tests__/gaps-satisfies.test.ts @@ -24,7 +24,7 @@ describe('satisfies enforcement', () => { // Any interface whose requirements num already meets (e.g., has eq). const iface = r.iface({ props: { - eq: { type: { name: 'function', call: { args: { name: 'object', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, + eq: { type: { name: 'function', call: { args: { name: 'obj', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, }, }); const named = r.extend(iface, { name: 'has-eq' }); @@ -39,7 +39,7 @@ describe('Registry.getTypesFor', () => { // Build an interface requiring a `toText` method. const iface = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); const named = r.extend(iface, { name: 'has-toText' }); diff --git a/packages/gin/src/__tests__/iface.test.ts b/packages/gin/src/__tests__/iface.test.ts index 30dd6c6..db090d1 100644 --- a/packages/gin/src/__tests__/iface.test.ts +++ b/packages/gin/src/__tests__/iface.test.ts @@ -8,7 +8,7 @@ describe('IfaceType', () => { test('builder accepts a spec', () => { const i = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); expect(i).toBeInstanceOf(IfaceType); @@ -17,7 +17,7 @@ describe('IfaceType', () => { test('compatible: type that has matching props satisfies interface', () => { const i = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); // num has toText — should satisfy diff --git a/packages/gin/src/__tests__/natives-collections.test.ts b/packages/gin/src/__tests__/natives-collections.test.ts index 5972452..34c57ba 100644 --- a/packages/gin/src/__tests__/natives-collections.test.ts +++ b/packages/gin/src/__tests__/natives-collections.test.ts @@ -74,7 +74,7 @@ describe('list natives', () => { }); const gt2 = { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'bool' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'gt' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }] }, }; expect((await e.run(program(gt2, 'some'))).raw).toBe(true); @@ -123,7 +123,7 @@ describe('map natives', () => { describe('obj natives', () => { test('keys/values/entries/has', async () => { - const objType = { name: 'object', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; + const objType = { name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; const obj = { kind: 'new', type: objType, value: { name: 'Alice', age: 30 } }; const call = (prop: string, args: any = {}) => e.run({ kind: 'define', vars: [{ name: 's', value: obj }], @@ -135,7 +135,7 @@ describe('obj natives', () => { }); test('indexed access', async () => { - const objType = { name: 'object', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; + const objType = { name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } } } }; const obj = { kind: 'new', type: objType, value: { name: 'Alice', age: 30 } }; const result = await e.run({ kind: 'define', vars: [{ name: 's', value: obj }], diff --git a/packages/gin/src/__tests__/person.test.ts b/packages/gin/src/__tests__/person.test.ts index a449d00..9f49d93 100644 --- a/packages/gin/src/__tests__/person.test.ts +++ b/packages/gin/src/__tests__/person.test.ts @@ -33,7 +33,7 @@ describe('Person.fullName integration', () => { template: { kind: 'new', type: { name: 'text' }, value: '{first} {last}' }, params: { kind: 'new', - type: { name: 'object', props: { + type: { name: 'obj', props: { first: { type: { name: 'text' } }, last: { type: { name: 'text' } }, } }, @@ -106,7 +106,7 @@ describe('Person.fullName integration', () => { template: { kind: 'new', type: { name: 'text' }, value: '{first} {last}' }, params: { kind: 'new', - type: { name: 'object', props: { + type: { name: 'obj', props: { first: { type: { name: 'text' } }, last: { type: { name: 'text' } }, } }, diff --git a/packages/gin/src/__tests__/readme.test.ts b/packages/gin/src/__tests__/readme.test.ts index 0222356..1423d45 100644 --- a/packages/gin/src/__tests__/readme.test.ts +++ b/packages/gin/src/__tests__/readme.test.ts @@ -68,7 +68,7 @@ describe('README examples', () => { kind: 'lambda', type: { name: 'function', - call: { args: { name: 'object' }, returns: { name: 'bool' } }, + call: { args: { name: 'obj' }, returns: { name: 'bool' } }, }, body: { kind: 'get', diff --git a/packages/gin/src/__tests__/recurse.test.ts b/packages/gin/src/__tests__/recurse.test.ts index 9b257d9..e34d138 100644 --- a/packages/gin/src/__tests__/recurse.test.ts +++ b/packages/gin/src/__tests__/recurse.test.ts @@ -21,7 +21,7 @@ describe('recurse in lambda body', () => { type: { name: 'function', call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, @@ -219,7 +219,7 @@ describe('recurse in CallDef.get', () => { const countdownFn = r.parse({ name: 'function', call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, get: { kind: 'if', @@ -283,7 +283,7 @@ describe('recurse in CallDef.set (method)', () => { const drainFn = r.parse({ name: 'function', call: { - args: { name: 'object', props: { k: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'num' } } } }, returns: { name: 'num' }, set: { kind: 'block', @@ -369,7 +369,7 @@ describe('recurse in CallDef.set (direct call)', () => { const fnType = r.parse({ name: 'function', call: { - args: { name: 'object', props: { k: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'num' } } } }, returns: { name: 'num' }, set: { kind: 'block', diff --git a/packages/gin/src/__tests__/recursive-types.test.ts b/packages/gin/src/__tests__/recursive-types.test.ts index a422f82..ec010df 100644 --- a/packages/gin/src/__tests__/recursive-types.test.ts +++ b/packages/gin/src/__tests__/recursive-types.test.ts @@ -14,7 +14,7 @@ import { createRegistry } from '../registry'; describe('recursive types', () => { test('self-reference: Node with optional children list', () => { const r = createRegistry(); - const Node = r.extend('object', { + const Node = r.extend('obj', { name: 'Node', props: { value: { type: r.num() }, @@ -33,7 +33,7 @@ describe('recursive types', () => { test('self-reference: parse a nested value through the ref', () => { const r = createRegistry(); - const Node = r.extend('object', { + const Node = r.extend('obj', { name: 'Node', props: { value: { type: r.num() }, @@ -58,7 +58,7 @@ describe('recursive types', () => { test('self-reference: JSON serializes the ref by NAME, not expanded', () => { const r = createRegistry(); - const Node = r.extend('object', { + const Node = r.extend('obj', { name: 'Node', props: { value: { type: r.num() }, @@ -79,7 +79,7 @@ describe('recursive types', () => { test('mutual cycle: Task ↔ User', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', props: { title: { type: r.text({ minLength: 1 }) }, @@ -88,7 +88,7 @@ describe('recursive types', () => { }); r.register(Task); - const User = r.extend('object', { + const User = r.extend('obj', { name: 'User', props: { name: { type: r.text() }, @@ -108,7 +108,7 @@ describe('recursive types', () => { test('mutual cycle: round-trips through JSON into a fresh registry', () => { const r = createRegistry(); - const Task = r.extend('object', { + const Task = r.extend('obj', { name: 'Task', props: { title: { type: r.text() }, @@ -116,7 +116,7 @@ describe('recursive types', () => { }, }); r.register(Task); - const User = r.extend('object', { + const User = r.extend('obj', { name: 'User', props: { name: { type: r.text() }, diff --git a/packages/gin/src/__tests__/scopes-typedef.test.ts b/packages/gin/src/__tests__/scopes-typedef.test.ts index 4972db6..7dbe499 100644 --- a/packages/gin/src/__tests__/scopes-typedef.test.ts +++ b/packages/gin/src/__tests__/scopes-typedef.test.ts @@ -441,7 +441,7 @@ describe('CallDef.get body', () => { const fnType = r.parse({ name: 'function', call: { - args: { name: 'object', props: { x: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' }, get: { kind: 'get', @@ -471,7 +471,7 @@ describe('PropDef.default', () => { const v = await e.run({ kind: 'new', type: { - name: 'object', + name: 'obj', props: { name: { type: { name: 'text' } }, greeting: { @@ -491,7 +491,7 @@ describe('PropDef.default', () => { const v = await e.run({ kind: 'new', type: { - name: 'object', + name: 'obj', props: { mode: { type: { name: 'text' }, @@ -517,7 +517,7 @@ describe('PathCall.catch scope', () => { type: { name: 'function', call: { - args: { name: 'object' }, + args: { name: 'obj' }, returns: { name: 'text' }, throws: { name: 'text' }, }, diff --git a/packages/gin/src/__tests__/super-override.test.ts b/packages/gin/src/__tests__/super-override.test.ts index db28af0..cf88581 100644 --- a/packages/gin/src/__tests__/super-override.test.ts +++ b/packages/gin/src/__tests__/super-override.test.ts @@ -345,7 +345,7 @@ describe('super in CallDef.set (method call.set) override', () => { const baseFn = r.parse({ name: 'function', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'block', @@ -383,7 +383,7 @@ describe('super in CallDef.set (method call.set) override', () => { const overrideFn = r.parse({ name: 'function', call: { - args: { name: 'object', props: { k: { type: { name: 'text' } } } }, + args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, set: { kind: 'block', diff --git a/packages/gin/src/__tests__/toCode.test.ts b/packages/gin/src/__tests__/toCode.test.ts index a19b67c..1614426 100644 --- a/packages/gin/src/__tests__/toCode.test.ts +++ b/packages/gin/src/__tests__/toCode.test.ts @@ -310,13 +310,13 @@ describe('Engine.toCode — expressions', () => { condition: { kind: 'new', type: { name: 'bool' }, value: true }, body: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'flow', action: 'return', value: { kind: 'new', type: { name: 'num' }, value: 7 } }, }, }], else: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }, { expectsValue: true }); @@ -363,7 +363,7 @@ describe('Engine.toCode — expressions', () => { kind: 'lambda', type: { name: 'function', - call: { args: { name: 'object', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' } }, + call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' } }, }, body: { kind: 'get', @@ -382,7 +382,7 @@ describe('Engine.toCode — expressions', () => { template: { kind: 'new', type: { name: 'text' }, value: 'Hello {name}!' }, params: { kind: 'new', - type: { name: 'object', props: { name: { type: { name: 'text' } } } }, + type: { name: 'obj', props: { name: { type: { name: 'text' } } } }, value: { name: 'Alice' }, }, }); diff --git a/packages/gin/src/__tests__/toSchema.test.ts b/packages/gin/src/__tests__/toSchema.test.ts index abd8bd3..6a2c04a 100644 --- a/packages/gin/src/__tests__/toSchema.test.ts +++ b/packages/gin/src/__tests__/toSchema.test.ts @@ -29,7 +29,7 @@ describe('toSchema / buildSchemas', () => { test('obj with nested props parses', () => { expect(() => Type.parse({ - name: 'object', + name: 'obj', props: { name: { type: { name: 'text' } }, age: { type: { name: 'num' } }, @@ -69,7 +69,7 @@ describe('toSchema / buildSchemas', () => { type: { name: 'function', call: { - args: { name: 'object', props: { n: { type: { name: 'num' } } } }, + args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, }, }, diff --git a/packages/gin/src/__tests__/validate-set.test.ts b/packages/gin/src/__tests__/validate-set.test.ts index 4fea540..99f6142 100644 --- a/packages/gin/src/__tests__/validate-set.test.ts +++ b/packages/gin/src/__tests__/validate-set.test.ts @@ -133,7 +133,7 @@ describe('validate set — negative cases (errors flagged)', () => { name: 'fn', value: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'object' }, returns: { name: 'num' } } }, + type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }], diff --git a/packages/gin/src/expr.ts b/packages/gin/src/expr.ts index b76a20c..1e72996 100644 --- a/packages/gin/src/expr.ts +++ b/packages/gin/src/expr.ts @@ -64,10 +64,25 @@ export abstract class Expr implements Node { return this; } + /** + * Whether this Expr's comment should render as a line comment + * (`// foo\n` on the line above) rather than an inline block + * (`/* foo *\/ expr`). Defaults to "line in statement context, inline + * in value context". Multi-line / statement-shaped Exprs (define / + * if / switch / block / lambda / loop / flow / set) override this to + * force the line form even when used in value position — a stacked + * `// note` reads better above a multi-line construct than a stray + * inline block at its head. + */ + protected useLineComment(options: CodeOptions = {}): boolean { + return options.expectsValue === false; + } + /** Rendered comment prefix for toCode. */ protected commentPrefix(options: CodeOptions = {}): string { if (!this.comment) return ''; - return options.expectsValue === false + if (options.includeComments === false) return ''; + return this.useLineComment(options) ? `// ${this.comment}\n` : `/* ${this.comment} */ `; } diff --git a/packages/gin/src/exprs/block.ts b/packages/gin/src/exprs/block.ts index ddf91c8..58c7889 100644 --- a/packages/gin/src/exprs/block.ts +++ b/packages/gin/src/exprs/block.ts @@ -25,6 +25,8 @@ export class BlockExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: BlockExprDef, scope: TypeScope): BlockExpr { const r = scope.registry; return new BlockExpr(json.lines.map((l) => r.parseExpr(l, scope))).withComment(json.comment); @@ -73,23 +75,32 @@ export class BlockExpr extends Expr { const prefix = this.commentPrefix(options); if (expectsValue) { + // Value-form needs the IIFE wrapper so the rendered code reads as + // a single expression. The wrapper supplies the `{ }`. const body = this.lines.map((line, i) => { const isLast = i === this.lines.length - 1; - const code = line.toCode(registry, { expectsValue: isLast }); + const code = line.toCode(registry, { ...options, expectsValue: isLast }); return isLast ? ` return ${indentCode(code)};` : ` ${indentCode(code)};`; }).join('\n'); return prefix + `(() => {\n${body}\n})()`; } - const body = this.lines.map((line) => { + // Statement-form: emit lines joined by newlines, no surrounding + // braces. The CALLER (fn body, if/else/for body, top-level + // engine.toCode) is responsible for wrapping in `{ ... }` if it + // needs a visual block. This keeps the output free of redundant + // double-brace artifacts when a `block` is the immediate body of + // another block-like construct. + return prefix + this.lines.map((line) => { const kind = (line as { kind: string }).kind; - const code = line.toCode(registry, { expectsValue: false }); - if (kind === 'if' || kind === 'switch' || kind === 'loop' || kind === 'block') { - return ` ${indentCode(code)}`; - } - return ` ${indentCode(code)};`; + const code = line.toCode(registry, { ...options, expectsValue: false }); + // Trailing `;` only for plain expressions / sets / defines — + // control-flow statements (if / switch / loop) already self- + // terminate, and a sub-block emits multiple lines that don't + // share one terminator. + if (kind === 'if' || kind === 'switch' || kind === 'loop' || kind === 'block') return code; + return `${code};`; }).join('\n'); - return prefix + `{\n${body}\n}`; } toJSON(): BlockExprDef { diff --git a/packages/gin/src/exprs/code.ts b/packages/gin/src/exprs/code.ts index a6dc0e6..90f1ea9 100644 --- a/packages/gin/src/exprs/code.ts +++ b/packages/gin/src/exprs/code.ts @@ -1,4 +1,5 @@ import type { Registry } from '../registry'; +import type { CodeOptions } from '../node'; import { Expr, type ChildBoundary } from '../expr'; import { FlowExpr } from './flow'; @@ -41,26 +42,34 @@ export function findEscapingFlow(expr: Expr, enclosing: ChildBoundary = 'inherit /** * Render an Expr as a statement-body for an if/else/for/switch branch. - * Flow statements render bare with a trailing `;`. Blocks render as - * already-braced statement sequences. Everything else is wrapped in - * `{ ...; }` so the containing control structure reads cleanly. + * + * Always emits a multi-line braced form so all branches read uniformly + * (no mixing of `} else { x; }` single-liners with multi-line if-bodies). + * Special cases: + * - `flow` (return / break / continue / throw / exit) renders bare + * plus `;` — `else return x;` is more readable than wrapping in + * braces just to terminate. + * - sub-`if` / `switch` / `loop` render in their own statement form + * (already self-bracing); used by `else if (...)` chains. + * - `block` emits its lines bare (BlockExpr no longer self-braces in + * statement form), so the wrapper here adds the `{` / `}`. + * - everything else: an expression statement wrapped in braces. */ -export function renderStatementBody(expr: Expr, registry?: Registry): string { - // Import lazily via require-esque pattern would create cycles; use - // structural markers instead of instanceof here to avoid the import. - // FlowExpr: render bare + `;`. BlockExpr: already braces itself in - // statement mode, reuse as-is. +export function renderStatementBody(expr: Expr, registry?: Registry, options: CodeOptions = {}): string { + // Use structural markers instead of instanceof to avoid a circular + // import on the concrete Expr classes. const kind = (expr as { kind: string }).kind; if (kind === 'flow') { - return `${expr.toCode(registry, { expectsValue: false })};`; + return `${expr.toCode(registry, { ...options, expectsValue: false })};`; + } + if (kind === 'if' || kind === 'switch' || kind === 'loop') { + return expr.toCode(registry, { ...options, expectsValue: false }); } if (kind === 'block') { - const code = expr.toCode(registry, { expectsValue: false }); + const code = expr.toCode(registry, { ...options, expectsValue: false }); return code.startsWith('{') ? code : `{\n ${indentCode(code)}\n}`; } - if (kind === 'if' || kind === 'switch' || kind === 'loop') { - return expr.toCode(registry, { expectsValue: false }); - } - // Expression statement — wrap in braces + `;`. - return `{ ${expr.toCode(registry, { expectsValue: true })}; }`; + // Expression statement — wrap in multi-line braces so the rendered + // code stays uniform across branch sizes. + return `{\n ${indentCode(expr.toCode(registry, { ...options, expectsValue: true }))};\n}`; } diff --git a/packages/gin/src/exprs/define.ts b/packages/gin/src/exprs/define.ts index 243d53b..4d40329 100644 --- a/packages/gin/src/exprs/define.ts +++ b/packages/gin/src/exprs/define.ts @@ -33,6 +33,8 @@ export class DefineExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: DefineExprDef, scope: TypeScope): DefineExpr { const r = scope.registry; const vars: DefineVar[] = json.vars.map((v) => ({ @@ -115,20 +117,22 @@ export class DefineExpr extends Expr { toCode(registry?: Registry, options: CodeOptions = {}): string { const expectsValue = options.expectsValue ?? false; + const valueOpts = { ...options, expectsValue: true }; + const stmtOpts = { ...options, expectsValue: false }; const lets = this.vars.map((v) => { - const typeAnno = v.type ? `: ${v.type.toCode()}` : ''; - return `const ${v.name}${typeAnno} = ${v.value.toCode(registry, { expectsValue: true })};`; + const typeAnno = v.type ? `: ${v.type.toCode(undefined, options)}` : ''; + return `const ${v.name}${typeAnno} = ${v.value.toCode(registry, valueOpts)};`; }); if (expectsValue) { - const body = this.body.toCode(registry, { expectsValue: true }); + const body = this.body.toCode(registry, valueOpts); const indented = [...lets.map((l) => ` ${l}`), ` return ${indentCode(body)};`].join('\n'); return this.commentPrefix(options) + `(() => {\n${indented}\n})()`; } // Statement form: const decls followed by body as a statement. const bodyKind = (this.body as { kind: string }).kind; - const bodyCode = this.body.toCode(registry, { expectsValue: false }); + const bodyCode = this.body.toCode(registry, stmtOpts); const bodyStmt = bodyKind === 'if' || bodyKind === 'switch' || bodyKind === 'loop' || bodyKind === 'block' || bodyKind === 'flow' ? (bodyKind === 'flow' ? `${bodyCode};` : bodyCode) : `${bodyCode};`; diff --git a/packages/gin/src/exprs/flow.ts b/packages/gin/src/exprs/flow.ts index 0b30fb0..396671b 100644 --- a/packages/gin/src/exprs/flow.ts +++ b/packages/gin/src/exprs/flow.ts @@ -31,6 +31,8 @@ export class FlowExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: FlowExprDef, scope: TypeScope): FlowExpr { const r = scope.registry; return new FlowExpr( @@ -107,14 +109,15 @@ export class FlowExpr extends Expr { */ toCode(registry?: Registry, options: CodeOptions = {}): string { const prefix = this.commentPrefix(options); + const valueOpts = { ...options, expectsValue: true }; let code: string; switch (this.action) { case 'break': code = 'break'; break; case 'continue': code = 'continue'; break; - case 'return': code = this.value ? `return ${this.value.toCode(registry, { expectsValue: true })}` : 'return'; break; - case 'throw': code = this.error ? `throw ${this.error.toCode(registry, { expectsValue: true })}` : 'throw'; break; + case 'return': code = this.value ? `return ${this.value.toCode(registry, valueOpts)}` : 'return'; break; + case 'throw': code = this.error ? `throw ${this.error.toCode(registry, valueOpts)}` : 'throw'; break; case 'exit': code = this.value - ? `/* exit */ return ${this.value.toCode(registry, { expectsValue: true })}` + ? `/* exit */ return ${this.value.toCode(registry, valueOpts)}` : '/* exit */ return'; break; default: code = ''; } diff --git a/packages/gin/src/exprs/get.ts b/packages/gin/src/exprs/get.ts index 48c9d75..64d1cd5 100644 --- a/packages/gin/src/exprs/get.ts +++ b/packages/gin/src/exprs/get.ts @@ -54,7 +54,7 @@ export class GetExpr extends Expr { } toCode(registry?: Registry, options: CodeOptions = {}): string { - return this.commentPrefix(options) + this.path.toCode(registry!); + return this.commentPrefix(options) + this.path.toCode(registry!, options); } toJSON(): GetExprDef { diff --git a/packages/gin/src/exprs/if.ts b/packages/gin/src/exprs/if.ts index 1450750..97f5478 100644 --- a/packages/gin/src/exprs/if.ts +++ b/packages/gin/src/exprs/if.ts @@ -30,6 +30,8 @@ export class IfExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: IfExprDef, scope: TypeScope): IfExpr { const r = scope.registry; const ifs = json.ifs.map((b) => ({ @@ -99,18 +101,19 @@ export class IfExpr extends Expr { const prefix = this.commentPrefix(options); + const valueOpts = { ...options, expectsValue: true }; // Expression context with no non-local flow: ternary or IIFE. if (expectsValue && !hasFlow) { if (this.ifs.length === 1 && this.otherwise) { const b = this.ifs[0]!; - return prefix + `(${b.condition.toCode(registry, { expectsValue: true })} ? ${b.body.toCode(registry, { expectsValue: true })} : ${this.otherwise.toCode(registry, { expectsValue: true })})`; + return prefix + `(${b.condition.toCode(registry, valueOpts)} ? ${b.body.toCode(registry, valueOpts)} : ${this.otherwise.toCode(registry, valueOpts)})`; } const branches = this.ifs.map((b, i) => { const kw = i === 0 ? 'if' : 'else if'; - return ` ${kw} (${b.condition.toCode(registry, { expectsValue: true })}) return ${indentCode(b.body.toCode(registry, { expectsValue: true }))};`; + return ` ${kw} (${b.condition.toCode(registry, valueOpts)}) return ${indentCode(b.body.toCode(registry, valueOpts))};`; }).join('\n'); const elseClause = this.otherwise - ? `\n return ${indentCode(this.otherwise.toCode(registry, { expectsValue: true }))};` + ? `\n return ${indentCode(this.otherwise.toCode(registry, valueOpts))};` : ''; return prefix + `(() => {\n${branches}${elseClause}\n})()`; } @@ -121,10 +124,10 @@ export class IfExpr extends Expr { const b = this.ifs[i]!; const kw = i === 0 ? 'if' : 'else if'; const leading = i === 0 ? '' : ' '; - out += `${leading}${kw} (${b.condition.toCode(registry, { expectsValue: true })}) ${renderStatementBody(b.body, registry)}`; + out += `${leading}${kw} (${b.condition.toCode(registry, valueOpts)}) ${renderStatementBody(b.body, registry, options)}`; } if (this.otherwise) { - out += ` else ${renderStatementBody(this.otherwise, registry)}`; + out += ` else ${renderStatementBody(this.otherwise, registry, options)}`; } return prefix + out; } diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index 4fdf80a..4706284 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -33,6 +33,8 @@ export class LambdaExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: LambdaExprDef, scope: TypeScope): LambdaExpr { const registry = scope.registry; // Parse the fn type first (FnType.from layers its own LocalScope @@ -125,16 +127,17 @@ export class LambdaExpr extends Expr { toCode(registry?: Registry, options: CodeOptions = {}): string { const call = this.fnType.call(); - const argsType = call?.args?.toCode() ?? 'any'; + const argsType = call?.args?.toCode(undefined, options) ?? 'any'; + const valueOpts = { ...options, expectsValue: true }; const prefix = this.commentPrefix(options); if (!this.constraint) { - return prefix + `(args: ${argsType}) => ${this.body.toCode(registry, { expectsValue: true })}`; + return prefix + `(args: ${argsType}) => ${this.body.toCode(registry, valueOpts)}`; } - const c = this.constraint.toCode(registry, { expectsValue: true }); + const c = this.constraint.toCode(registry, valueOpts); // Render the constraint as an inline guard so readers see both the // precondition and the body. return prefix - + `(args: ${argsType}) => { if (!(${c})) throw new Error('constraint'); return ${this.body.toCode(registry, { expectsValue: true })}; }`; + + `(args: ${argsType}) => { if (!(${c})) throw new Error('constraint'); return ${this.body.toCode(registry, valueOpts)}; }`; } toJSON(): LambdaExprDef { diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 8207b6f..5241bd1 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -37,6 +37,8 @@ export class LoopExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: LoopExprDef, scope: TypeScope): LoopExpr { const r = scope.registry; const parallel = json.parallel ? { @@ -263,30 +265,34 @@ export class LoopExpr extends Expr { toCode(registry?: Registry, options: CodeOptions = {}): string { const expectsValue = options.expectsValue ?? false; - const over = this.over.toCode(registry, { expectsValue: true }); + const valueOpts = { ...options, expectsValue: true }; + const stmtOpts = { ...options, expectsValue: false }; + const over = this.over.toCode(registry, valueOpts); const key = this.keyName ?? 'key'; const value = this.valueName ?? 'value'; let prefix = ''; - if (this.parallel?.concurrent) { - prefix += `/* parallel.concurrent: ${this.parallel.concurrent.toCode(registry, { expectsValue: true })} */ `; - } - if (this.parallel?.rate) { - prefix += `/* parallel.rate: ${this.parallel.rate.toCode(registry, { expectsValue: true })} */ `; + if (options.includeComments !== false) { + if (this.parallel?.concurrent) { + prefix += `/* parallel.concurrent: ${this.parallel.concurrent.toCode(registry, valueOpts)} */ `; + } + if (this.parallel?.rate) { + prefix += `/* parallel.rate: ${this.parallel.rate.toCode(registry, valueOpts)} */ `; + } } // Body in statement context — uses bare statements / flow / nested control. const bodyStmt = (() => { const kind = (this.body as { kind: string }).kind; - if (kind === 'flow') return `${this.body.toCode(registry, { expectsValue: false })};`; + if (kind === 'flow') return `${this.body.toCode(registry, stmtOpts)};`; if (kind === 'block') { - const code = this.body.toCode(registry, { expectsValue: false }); + const code = this.body.toCode(registry, stmtOpts); return code.startsWith('{') ? code.slice(1, -1).trim() : code; } if (kind === 'if' || kind === 'switch' || kind === 'loop') { - return this.body.toCode(registry, { expectsValue: false }); + return this.body.toCode(registry, stmtOpts); } - return `${this.body.toCode(registry, { expectsValue: false })};`; + return `${this.body.toCode(registry, stmtOpts)};`; })(); const forStmt = `${prefix}for (const [${key}, ${value}] of ${over}) {\n ${indentCode(bodyStmt)}\n}`; diff --git a/packages/gin/src/exprs/new.ts b/packages/gin/src/exprs/new.ts index deb5aff..5344cb5 100644 --- a/packages/gin/src/exprs/new.ts +++ b/packages/gin/src/exprs/new.ts @@ -4,7 +4,7 @@ import type { NewExprDef, ExprDef } from '../schema'; import { Value, val } from '../value'; import { ObjType } from '../types/obj'; import type { Registry } from '../registry'; -import type { Type } from '../type'; +import { joinAuto, type Type } from '../type'; import type { Locals } from '../analysis'; import type { Problems } from '../problem'; import { Expr, type ValidateContext } from '../expr'; @@ -140,8 +140,8 @@ export class NewExpr extends Expr { return this.type; } - toCode(_registry?: Registry, options: CodeOptions = {}): string { - const typeName = this.type.toCode(); + toCode(registry?: Registry, options: CodeOptions = {}): string { + const typeName = this.type.toCode(undefined, options); let code: string; if (this.value === undefined) { // An omitted value on an optional type IS `undefined`; otherwise @@ -151,7 +151,7 @@ export class NewExpr extends Expr { else if (typeof this.value === 'number' || typeof this.value === 'boolean') code = String(this.value); else if (typeof this.value === 'string') code = JSON.stringify(this.value); else if (this.value === null) code = 'null'; - else code = `${JSON.stringify(this.value)} as ${typeName}`; + else code = renderNewValue(this.value, registry, typeName, options); return this.commentPrefix(options) + code; } @@ -164,6 +164,70 @@ export class NewExpr extends Expr { } } +/** + * Render the `value` slot of a `{kind:'new'}` expression as readable + * source instead of a raw `JSON.stringify(...) as TypeName` dump. + * + * The value can hold: + * - primitive (already handled by the caller) + * - ExprDef (object with `kind`) — recurse via the registry's + * `parseExpr` and call its `toCode`. Mirrors how a hand-written + * `new list { value: [, ] }` would read. + * - array — list-shaped `new`; render `[item, item]` with each slot + * recursed. + * - plain object — obj-shaped `new`; render `{ key: value, ... }` + * with each value recursed. + * + * Without a registry we can't parse ExprDefs back into Exprs; in that + * case we still recurse over the array / object structure but render + * primitive leaves directly and bail to JSON.stringify for any + * ExprDef-shaped node we can't decode. + */ +function renderNewValue(value: unknown, registry: Registry | undefined, typeName: string, options: CodeOptions = {}): string { + // ExprDef-shaped → render via the parsed Expr's toCode. + if (registry && value && typeof value === 'object' && !Array.isArray(value) + && 'kind' in (value as Record) + && typeof (value as { kind: unknown }).kind === 'string') { + const kind = (value as { kind: string }).kind; + if (registry.exprClass(kind)) { + try { + return registry.parseExpr(value as ExprDef).toCode(registry, { ...options, expectsValue: true }); + } catch { /* fall through to literal rendering */ } + } + } + + if (Array.isArray(value)) { + const parts = value.map((v) => renderNewValueLeaf(v, registry, options)); + const joined = joinAuto(parts); + return joined.startsWith('\n') ? `[${joined}]` : `[${joined}]`; + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) return `new ${typeName}()`; + const parts = entries.map(([k, v]) => `${k}: ${renderNewValueLeaf(v, registry, options)}`); + const joined = joinAuto(parts); + return joined.startsWith('\n') + ? `new ${typeName} {${joined}}` + : `new ${typeName} { ${joined} }`; + } + + // Last resort — primitive / null already handled in caller; this + // is for unexpected shapes. + return JSON.stringify(value); +} + +function renderNewValueLeaf(v: unknown, registry: Registry | undefined, options: CodeOptions = {}): string { + if (v === null) return 'null'; + if (v === undefined) return 'undefined'; + if (typeof v === 'string') return JSON.stringify(v); + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + // Recurse — typeName is unknown at leaf depth, so use a generic + // marker; composite leaves render as `[...]` or `{ k: v }` without + // the `new ` prefix. + return renderNewValue(v, registry, '', options); +} + /** * For an Obj type (including Extensions over Obj via `.base` delegation), * evaluate each field's `default` Expr for any missing input key. Leaves diff --git a/packages/gin/src/exprs/set.ts b/packages/gin/src/exprs/set.ts index c421b56..6a01f9c 100644 --- a/packages/gin/src/exprs/set.ts +++ b/packages/gin/src/exprs/set.ts @@ -26,6 +26,8 @@ export class SetExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: SetExprDef, scope: TypeScope): SetExpr { return new SetExpr(Path.from(json.path, scope), scope.registry.parseExpr(json.value, scope)) .withComment(json.comment); @@ -73,7 +75,7 @@ export class SetExpr extends Expr { toCode(registry?: Registry, options: CodeOptions = {}): string { return this.commentPrefix(options) - + `${this.path.toCode(registry!)} = ${this.value.toCode(registry, { expectsValue: true })}`; + + `${this.path.toCode(registry!, options)} = ${this.value.toCode(registry, { ...options, expectsValue: true })}`; } toJSON(): SetExprDef { diff --git a/packages/gin/src/exprs/switch.ts b/packages/gin/src/exprs/switch.ts index 53585fb..5511456 100644 --- a/packages/gin/src/exprs/switch.ts +++ b/packages/gin/src/exprs/switch.ts @@ -35,6 +35,8 @@ export class SwitchExpr extends Expr { super(); } + protected useLineComment(options: CodeOptions = {}): boolean { return !options.expectsValue; } + static from(json: SwitchExprDef, scope: TypeScope): SwitchExpr { const r = scope.registry; return new SwitchExpr( @@ -116,28 +118,29 @@ export class SwitchExpr extends Expr { this.cases.some((c) => !!findEscapingFlow(c.body)) || (this.otherwise ? !!findEscapingFlow(this.otherwise) : false); - const head = this.value.toCode(registry, { expectsValue: true }); + const valueOpts = { ...options, expectsValue: true }; + const head = this.value.toCode(registry, valueOpts); const prefix = this.commentPrefix(options); if (expectsValue && !hasFlow) { const cases = this.cases.map((c) => { - const labels = c.equals.map((e) => ` case ${e.toCode(registry, { expectsValue: true })}:`).join('\n'); - return `${labels}\n return ${indentCode(c.body.toCode(registry, { expectsValue: true }))};`; + const labels = c.equals.map((e) => ` case ${e.toCode(registry, valueOpts)}:`).join('\n'); + return `${labels}\n return ${indentCode(c.body.toCode(registry, valueOpts))};`; }).join('\n'); const def = this.otherwise - ? `\n default:\n return ${indentCode(this.otherwise.toCode(registry, { expectsValue: true }))};` + ? `\n default:\n return ${indentCode(this.otherwise.toCode(registry, valueOpts))};` : ''; return prefix + `(() => {\n switch (${head}) {\n${cases}${def}\n }\n})()`; } const cases = this.cases.map((c) => { - const labels = c.equals.map((e) => ` case ${e.toCode(registry, { expectsValue: true })}:`).join('\n'); - const bodyCode = renderStatementBody(c.body, registry); + const labels = c.equals.map((e) => ` case ${e.toCode(registry, valueOpts)}:`).join('\n'); + const bodyCode = renderStatementBody(c.body, registry, options); const tail = c.body instanceof FlowExpr ? '' : '\n break;'; return `${labels}\n ${indentCode(bodyCode)}${tail}`; }).join('\n'); const def = this.otherwise - ? `\n default:\n ${indentCode(renderStatementBody(this.otherwise, registry))}` + ? `\n default:\n ${indentCode(renderStatementBody(this.otherwise, registry, options))}` : ''; return prefix + `switch (${head}) {\n${cases}${def}\n}`; } diff --git a/packages/gin/src/exprs/template.ts b/packages/gin/src/exprs/template.ts index 54a3dc8..e97c65d 100644 --- a/packages/gin/src/exprs/template.ts +++ b/packages/gin/src/exprs/template.ts @@ -84,7 +84,7 @@ export class TemplateExpr extends Expr { const paramsT = p.at('params', () => walkValidate(engine, this.params, scope, p, ctx)); // params must be an object-shaped type so that `{name}` placeholders // can be looked up. - if (paramsT.name !== 'object' && paramsT.name !== 'any') { + if (paramsT.name !== 'obj' && paramsT.name !== 'any') { p.at('params', () => p.warn('template.params.type', `template params should be an object, got '${paramsT.name}'`)); } @@ -97,7 +97,7 @@ export class TemplateExpr extends Expr { : undefined; const prefix = this.commentPrefix(options); if (raw === undefined) { - return prefix + `template(${registry!.toCode(this.template)}, ${registry!.toCode(this.params)})`; + return prefix + `template(${registry!.toCode(this.template, options)}, ${registry!.toCode(this.params, options)})`; } const inline = tryInlineTemplateParams(this.params, registry!); const converted = raw.replace(/\{(\w+)\}/g, (_, name) => @@ -124,14 +124,28 @@ export class TemplateExpr extends Expr { } } -function tryInlineTemplateParams(params: Expr, _registry: Registry): Record | undefined { +function tryInlineTemplateParams(params: Expr, registry: Registry): Record | undefined { if (!(params instanceof NewExpr)) return undefined; const value = params.value; if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { if (typeof v === 'string') out[k] = JSON.stringify(v); - else out[k] = String(v); + else if (typeof v === 'number' || typeof v === 'boolean') out[k] = String(v); + else if (v === null) out[k] = 'null'; + else if (v && typeof v === 'object' && 'kind' in (v as Record) + && typeof (v as { kind: unknown }).kind === 'string' + && registry.exprClass((v as { kind: string }).kind)) { + // ExprDef-shaped: render via the parsed Expr's toCode so a `get` + // path appears as `args.text` instead of `[object Object]`. + try { + out[k] = registry.parseExpr(v as ExprDef).toCode(registry, { expectsValue: true }); + } catch { return undefined; } + } else { + // Unknown shape — bail out of inlining so the caller falls back + // to the safe `template(, )` form. + return undefined; + } } return out; } diff --git a/packages/gin/src/extension.ts b/packages/gin/src/extension.ts index e207c62..82c927b 100644 --- a/packages/gin/src/extension.ts +++ b/packages/gin/src/extension.ts @@ -17,7 +17,7 @@ import type { Scope } from './scope'; import type { Engine } from './engine'; import type { JSONOf, RuntimeOf } from './json-type'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from './node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from './node'; import type { Expr } from './expr'; /** @@ -32,7 +32,7 @@ import type { Expr } from './expr'; * extra `TypeScope` passed at access time (e.g. a path call site's * `` bindings) before falling back to the captured layer. * - * registry.extend('object', { + * registry.extend('obj', { * name: 'Box', * generic: { T: registry.any() }, * props: { value: { type: registry.alias('T') } }, @@ -286,13 +286,13 @@ export class Extension extends Type { }); } - toCode(): string { return this.docsPrefix() + this.name; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + this.name; } /** Renders `type Email extends text{pattern="..."}` headers in * `toCodeDefinition`. Uses `base` (narrowed) rather than `original` * so the constraints the Extension sits atop are visible. */ - protected extendsClause(): string { - return ` extends ${this.base.toCode()}`; + protected extendsClause(options?: CodeOptions): string { + return ` extends ${this.base.toCode(undefined, options)}`; } // Definition hooks — an Extension's rendered body shows only the diff --git a/packages/gin/src/node.ts b/packages/gin/src/node.ts index ff641b4..d6dfac3 100644 --- a/packages/gin/src/node.ts +++ b/packages/gin/src/node.ts @@ -107,6 +107,16 @@ export interface SchemaOptions extends ValueSchemaOptions { export interface CodeOptions { expectsValue?: boolean; indent?: string; + /** + * When false, suppress all `/* docs * /` and `// comment` rendering — + * Type docstrings, Prop docs, Expr comments, and `// docs` lines on + * Init / Call / Prop in `toCodeDefinition`. Default true (include). + * + * Threading this through inner `.toCode(...)` / `.toCodeDefinition(...)` + * calls is the responsibility of each composite type / expr — callers + * that want a comment-free render set this once at the top. + */ + includeComments?: boolean; } /** diff --git a/packages/gin/src/path.ts b/packages/gin/src/path.ts index af16212..fc2d11a 100644 --- a/packages/gin/src/path.ts +++ b/packages/gin/src/path.ts @@ -13,6 +13,8 @@ import type { Locals } from './analysis'; import type { Problems } from './problem'; import type { ValidateContext } from './expr'; import { LocalScope, type TypeScope } from './type-scope'; +import { joinAuto } from './type'; +import type { CodeOptions } from './node'; /** * Path — a sequence of steps against a starting value. The third citizen @@ -152,7 +154,7 @@ export class Path { } } - toCode(registry: Registry): string { + toCode(registry: Registry, options: CodeOptions = {}): string { if (this.steps.length === 0) return ''; let out = ''; for (let i = 0; i < this.steps.length; i++) { @@ -161,15 +163,25 @@ export class Path { out = i === 0 ? step.prop : `${out}.${step.prop}`; } else if (step instanceof CallStep) { const entries = Object.entries(step.args); - const body = entries.length === 0 - ? '{}' - : `{ ${entries.map(([k, v]) => `${k}: ${v.toCode(registry)}`).join(', ')} }`; - out += `(${body})`; + if (entries.length === 0) { + out += '({})'; + } else { + const parts = entries.map(([k, v]) => `${k}: ${v.toCode(registry, { ...options, expectsValue: true })}`); + const joined = joinAuto(parts); + // joinAuto returns `\n …\n` for the wrapped form, plain + // `a, b` for the compact form. Brace-spacing matches each. + out += joined.startsWith('\n') ? `({${joined}})` : `({ ${joined} })`; + } if (step.catch_) { - out += ` /* catch: ${step.catch_.toCode(registry).replace(/\*\//g, '*_/')} */`; + // Render `catch:` as a JS-like `.catch(err => …)` chain so + // it survives nested comments / complex catch bodies. The + // call expects `error` in scope; the rendered handler + // signature mirrors that. + const handler = step.catch_.toCode(registry, { ...options, expectsValue: true }); + out += `.catch((error) => ${handler})`; } } else if (step instanceof IndexStep) { - out += `[${step.key.toCode(registry)}]`; + out += `[${step.key.toCode(registry, { ...options, expectsValue: true })}]`; } } return out; diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index dd77afe..d5f8108 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -96,6 +96,13 @@ export class Prop { * Invoke this prop as a method: runs get Expr with {this, args, super?, recurse}. * `fnType` is the effective (possibly generic-bound) Fn type used for the * recurse Value's type; defaults to this.type. + * + * When the prop has no `get` expression — the case for natively-installed + * globals like `fns.fetch` / `fns.llm` whose obj-field raw is a JS + * callable — fall back to invoking the raw value directly. This mirrors + * `Prop.read`'s direct-field fallback and the value-call branch in + * `Path.walk` (which is the path taken when the call follows a value + * read, not a method dispatch). */ async invokeMethod( self: Value, @@ -105,8 +112,23 @@ export class Prop { engine: Engine, fnType?: Type, ): Promise { - if (!this.get) throw new Error(`path: callable prop '${name}' has no get expression`); const effectiveType = fnType ?? this.type; + if (!this.get) { + const raw = (self.raw as Record | null | undefined)?.[name]; + const target = raw instanceof Value ? raw.raw : raw; + if (typeof target === 'function') { + return await (target as (a: Value) => Promise)(argsValue); + } + // Stored ExprDef (a lambda saved as JSON) — evaluate it to a callable + // Value first, then invoke. Mirrors how saved-fn globals dispatch. + if (target && typeof target === 'object' && 'kind' in (target as Record)) { + const lambdaValue = await engine.evaluate(target as ExprDef, scope); + if (typeof lambdaValue.raw === 'function') { + return await (lambdaValue.raw as (a: Value) => Promise)(argsValue); + } + } + throw new Error(`path: callable prop '${name}' has no get expression and raw is not a callable`); + } const getExpr = this.get; const callable = async (newArgs: Value): Promise => { const recurseValue = new Value(effectiveType, callable); @@ -736,17 +758,22 @@ export abstract class Type implements Node { abstract toCode(registry?: Registry, options?: CodeOptions): string; /** - * Inline `/* docs * /` prefix for `toCode` output when this type has docs. - * Mirrors `Expr.commentPrefix`. Subclasses that want docs rendered call - * `this.docsPrefix() + ` from their `toCode` implementation. + * Inline `/* docs * /` prefix for `toCode` output. Always returns empty + * by default — type docs would otherwise repeat at every reference, + * burying real signal in noise (every `args.x: T` annotation, every + * `new T {...}`, every type parameter would carry the full prose). Docs + * stay on the type definition (rendered as a `// docs` header by + * `toCodeDefinition`) where they describe the type ONCE. The hook + * exists so a subclass could opt back in with policy if needed; today + * none do. */ - protected docsPrefix(): string { - return this.docs ? `/* ${this.docs} */ ` : ''; + protected docsPrefix(_options?: CodeOptions): string { + return ''; } /** ` extends ` clause on the `type ` header — empty for * built-in classes; Extension overrides to show its base type. */ - protected extendsClause(): string { + protected extendsClause(_options?: CodeOptions): string { return ''; } @@ -777,8 +804,9 @@ export abstract class Type implements Node { * [key: "title" | "done" | "due"]: string | boolean | Date | undefined * } */ - toCodeDefinition(): string { + toCodeDefinition(options?: CodeOptions): string { const lines: string[] = []; + const includeComments = options?.includeComments !== false; // Call-local type aliases — rendered first so they read like // class-level type-alias declarations and can be referenced when @@ -786,32 +814,32 @@ export abstract class Type implements Node { const call = this.definitionCall(); if (call?.types) { for (const [name, t] of Object.entries(call.types)) { - lines.push(` type ${name} = ${t.toCode()};`); + lines.push(` type ${name} = ${t.toCode(undefined, options)};`); } } // Constructor — rendered first so the shape reads like a class. const init = this.definitionInit(); if (init) { - if (init.docs) lines.push(` // ${init.docs}`); - lines.push(` new(${formatParams(init.args)})`); + if (init.docs && includeComments) lines.push(` // ${init.docs}`); + lines.push(` new(${formatParams(init.args, options)})`); } // Call signature (`fn` / iface with call / Extension with call). if (call) { - const ret = call.returns?.toCode() ?? 'void'; - lines.push(` (${formatParams(call.args)}): ${ret}`); + const ret = call.returns?.toCode(undefined, options) ?? 'void'; + lines.push(` (${formatParams(call.args, options)}): ${ret}`); } // Index signature. const gs = this.definitionGet(); - if (gs) lines.push(` [key: ${gs.key.toCode()}]: ${gs.value.toCode()}`); + if (gs) lines.push(` [key: ${gs.key.toCode(undefined, options)}]: ${gs.value.toCode(undefined, options)}`); // Fields + methods. const ownGenerics = new Set(Object.keys(this.generic)); for (const [name, raw] of Object.entries(this.definitionProps())) { const prop = raw instanceof Prop ? raw : Prop.from(raw); - if (prop.docs) lines.push(` // ${prop.docs}`); + if (prop.docs && includeComments) lines.push(` // ${prop.docs}`); const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; const opt = optional ? '?' : ''; @@ -825,21 +853,21 @@ export abstract class Type implements Node { && !t.get() && nonUniversalKeys.length === 0; if (pureCallable) { - const ret = propCall!.returns?.toCode() ?? 'void'; + const ret = propCall!.returns?.toCode(undefined, options) ?? 'void'; // Method-level generics — declared on the fn's `.generic`, filtered // to those NOT inherited from the outer type's own generics. const methodGen = Object.fromEntries( Object.entries(t.generic).filter(([k]) => !ownGenerics.has(k)), ); - const gParams = renderGenerics(methodGen); - lines.push(` ${name}${opt}${gParams}(${formatParams(propCall!.args)}): ${ret}`); + const gParams = renderGenerics(methodGen, options); + lines.push(` ${name}${opt}${gParams}(${formatParams(propCall!.args, options)}): ${ret}`); } else { - lines.push(` ${name}${opt}: ${t.toCode()}`); + lines.push(` ${name}${opt}: ${t.toCode(undefined, options)}`); } } - const docLine = this.docs ? `// ${this.docs}\n` : ''; - const header = `${docLine}type ${this.name}${renderGenerics(this.generic)}${this.extendsClause()}`; + const docLine = this.docs && includeComments ? `// ${this.docs}\n` : ''; + const header = `${docLine}type ${this.name}${renderGenerics(this.generic, options)}${this.extendsClause(options)}`; return lines.length === 0 ? `${header} {}` : `${header} {\n${lines.join('\n')}\n}`; } @@ -900,15 +928,31 @@ export abstract class Type implements Node { } /** - * Serialize a type's `options` as gin's `{key=value, …}` suffix. Empty / - * all-undefined options render as the empty string, so primitives without - * narrowing (`num`, `text`) stay bare. Values use JSON encoding for - * strings / null / arrays / objects; numbers and booleans render literal. + * Serialize a type's `options` as gin's `{key=value, …}` suffix. Empty + * / all-undefined options render as the empty string, so primitives + * without narrowing (`num`, `text`) stay bare. + * + * Skips noise that adds no information: + * - undefined values + * - empty strings (`prefix=""`, `suffix=""`) + * - entries equal to the optional `defaults` map (per-type + * "uninteresting default" — e.g. `minPrecision=1` on num is rarely + * worth surfacing; if equal to the default, don't render it) + * + * Values use JSON encoding for strings / null / arrays / objects; + * numbers and booleans render as literals. */ -export function optionsCode(opts: object | undefined | null): string { +export function optionsCode( + opts: object | undefined | null, + defaults?: Record, +): string { if (!opts) return ''; - const entries = Object.entries(opts as Record) - .filter(([, v]) => v !== undefined); + const entries = Object.entries(opts as Record).filter(([k, v]) => { + if (v === undefined) return false; + if (v === '') return false; + if (defaults && k in defaults && deepEqual(defaults[k], v)) return false; + return true; + }); if (entries.length === 0) return ''; const parts = entries.map(([k, v]) => { const encoded = typeof v === 'string' @@ -920,7 +964,16 @@ export function optionsCode(opts: object | undefined | null): string { : JSON.stringify(v); return `${k}=${encoded}`; }); - return `{${parts.join(', ')}}`; + return `{${joinAuto(parts)}}`; +} + +/** Cheap deep-equality for `optionsCode`'s defaults skip. Stable JSON + * stringify is good enough — option values are small primitives / + * arrays / records, not class instances or functions. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + try { return JSON.stringify(a) === JSON.stringify(b); } + catch { return false; } } /** @@ -930,12 +983,13 @@ export function optionsCode(opts: object | undefined | null): string { */ export function renderCallTypes( types: Record | undefined, + options?: CodeOptions, ): string { if (!types) return ''; const keys = Object.keys(types); if (keys.length === 0) return ''; - const parts = keys.map((k) => `${k}: ${types[k]!.toCode()}`); - return `{${parts.join('; ')}}`; + const parts = keys.map((k) => `${k}: ${types[k]!.toCode(undefined, options)}`); + return `{${joinAuto(parts, { sep: '; ' })}}`; } /** @@ -944,16 +998,19 @@ export function renderCallTypes( * placeholder, `T: code` otherwise. Shared by type headers and fn * signatures. */ -export function renderGenerics(generic: Record): string { +export function renderGenerics( + generic: Record, + options?: CodeOptions, +): string { const keys = Object.keys(generic); if (keys.length === 0) return ''; const parts = keys.map((k) => { const t = generic[k]!; const selfRef = t.name === 'alias' && (t.options as { name?: string } | undefined)?.name === k; - return t.name === 'any' || selfRef ? k : `${k}: ${t.toCode()}`; + return t.name === 'any' || selfRef ? k : `${k}: ${t.toCode(undefined, options)}`; }); - return `<${parts.join(', ')}>`; + return `<${joinAuto(parts)}>`; } /** @@ -962,16 +1019,53 @@ export function renderGenerics(generic: Record): string { * type for args, so duck-typing on `.fields` covers the common case; * anything else falls back to a single `args: ` param. */ -export function formatParams(args: Type): string { +export function formatParams(args: Type, options?: CodeOptions): string { const fields = (args as unknown as { fields?: Record }).fields; if (!fields) return args.name === 'void' || args.name === 'any' ? '' - : `args: ${args.toCode()}`; + : `args: ${args.toCode(undefined, options)}`; const parts = Object.entries(fields).map(([name, prop]) => { const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; - const docs = prop.docs ? `/* ${prop.docs} */ ` : ''; - return `${docs}${name}${optional ? '?' : ''}: ${t.toCode()}`; + const docs = prop.docs && options?.includeComments !== false ? `/* ${prop.docs} */ ` : ''; + return `${docs}${name}${optional ? '?' : ''}: ${t.toCode(undefined, options)}`; }); - return parts.join(', '); + return joinAuto(parts); +} + +/** + * Delimiter-join with automatic wrapping for long content. Used by + * every comma- or semicolon-delimited renderer (params, call args, + * new-list / new-obj literals, call.types alias headers, …) so they + * stay readable at any depth. + * + * Compact form: `a b c` — when every item is short and + * single-line. Default separator is `, ` for comma-lists; pass `'; '` + * for semicolon-lists (e.g. `call.types` alias headers). + * + * Wrapped form: leading `\n`, each item indented by two spaces and + * followed by `<\n>`, trailing `\n` before the caller's + * closing delimiter: + * + * `(\n a: very-long-type,\n b: another-long-type\n)` + * + * Triggers when ANY item exceeds `threshold` characters (default 32) + * or itself contains a newline (already wrapped at a deeper level). + * Already-multi-line items get their newlines indented so nesting + * doesn't lose alignment. + */ +export function joinAuto( + items: string[], + opts: { sep?: string; threshold?: number } = {}, +): string { + if (items.length === 0) return ''; + const sep = opts.sep ?? ', '; + const threshold = opts.threshold ?? 32; + const wrap = items.some((i) => i.length > threshold || i.includes('\n')); + if (!wrap) return items.join(sep); + // Strip trailing whitespace from the separator so the wrapped form + // emits e.g. `,\n` (not `, \n`) — newline already does the spacing. + const wrapSep = sep.replace(/\s+$/, ''); + const indented = items.map((i) => ' ' + i.replace(/\n/g, '\n ')); + return `\n${indented.join(`${wrapSep}\n`)}\n`; } diff --git a/packages/gin/src/types/alias.ts b/packages/gin/src/types/alias.ts index 35cfa4e..48fa4f0 100644 --- a/packages/gin/src/types/alias.ts +++ b/packages/gin/src/types/alias.ts @@ -1,4 +1,5 @@ import type { PathStepDef, TypeDef } from '../schema'; +import type { Registry } from '../registry'; import { Value } from '../value'; import { type Call, @@ -12,7 +13,7 @@ import { } from '../type'; import type { TypeScope } from '../type-scope'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface AliasOptions { @@ -175,8 +176,8 @@ export class AliasType extends Type { return new AliasType(this.scope, { ...this.options }); } - toCode(): string { - return this.docsPrefix() + this.options.name; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + this.options.name; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/and.ts b/packages/gin/src/types/and.ts index 613d67f..ff52ff3 100644 --- a/packages/gin/src/types/and.ts +++ b/packages/gin/src/types/and.ts @@ -5,7 +5,7 @@ import { Value } from '../value'; import { Call, type CompatOptions, GetSet, type Prop, PropSpec, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface AndOptions { @@ -165,8 +165,8 @@ export class AndType extends Type { return new AndType(this.registry, this.parts.map((p) => p.clone())); } - toCode(): string { - return this.docsPrefix() + `and<${this.parts.map((p) => p.toCode()).join(', ')}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `and<${this.parts.map((p) => p.toCode(undefined, options)).join(', ')}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/any.ts b/packages/gin/src/types/any.ts index 96743cf..56608d0 100644 --- a/packages/gin/src/types/any.ts +++ b/packages/gin/src/types/any.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -99,7 +100,7 @@ export class AnyType extends Type> { return new AnyType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'any'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'any'; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.any(), opts); } diff --git a/packages/gin/src/types/bool.ts b/packages/gin/src/types/bool.ts index 3e960f0..2cc7fc6 100644 --- a/packages/gin/src/types/bool.ts +++ b/packages/gin/src/types/bool.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { BoolOptions } from '../builder'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -118,7 +119,7 @@ export class BoolType extends Type { return new BoolType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'bool' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'bool' + optionsCode(this.options); } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.boolean(), opts); } diff --git a/packages/gin/src/types/color.ts b/packages/gin/src/types/color.ts index 0f2ce48..cb746d9 100644 --- a/packages/gin/src/types/color.ts +++ b/packages/gin/src/types/color.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, Init, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { ColorOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -131,7 +132,7 @@ export class ColorType extends Type { return new ColorType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'color' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'color' + optionsCode(this.options); } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is a 32-bit integer (0xRRGGBBAA or 0xRRGGBB depending on hasAlpha). diff --git a/packages/gin/src/types/date.ts b/packages/gin/src/types/date.ts index b5e54c0..cffee47 100644 --- a/packages/gin/src/types/date.ts +++ b/packages/gin/src/types/date.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { DateOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -145,7 +146,7 @@ export class DateType extends Type { return new DateType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'date' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'date' + optionsCode(this.options); } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is an ISO date string (YYYY-MM-DD). diff --git a/packages/gin/src/types/duration.ts b/packages/gin/src/types/duration.ts index 7657bab..e666498 100644 --- a/packages/gin/src/types/duration.ts +++ b/packages/gin/src/types/duration.ts @@ -1,9 +1,10 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, Init, type Prop, type Rnd, Type } from '../type'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -105,7 +106,7 @@ export class DurationType extends Type> { return new DurationType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'duration'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'duration'; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is a number of milliseconds. diff --git a/packages/gin/src/types/enum.ts b/packages/gin/src/types/enum.ts index 7c2d98c..b9dfdcc 100644 --- a/packages/gin/src/types/enum.ts +++ b/packages/gin/src/types/enum.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -155,9 +156,9 @@ export class EnumType extends Type> { ); } - toCode(): string { - const body = `enum<${this.value.toCode()}>` + optionsCode(this.options.values as Record); - return this.docsPrefix() + body; + toCode(_registry?: Registry, options?: CodeOptions): string { + const body = `enum<${this.value.toCode(undefined, options)}>` + optionsCode(this.options.values as Record); + return this.docsPrefix(options) + body; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 225ff1e..3b369a0 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -1,10 +1,11 @@ import type { ExprDef, TypeDef } from '../schema'; +import type { Registry } from '../registry'; import { Value } from '../value'; import { Call, type CompatOptions, type Prop, type Rnd, Type, formatParams, renderCallTypes, renderGenerics } from '../type'; import { decodeCall } from '../spec'; import { LocalScope, type TypeScope } from '../type-scope'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema } from '../schemas'; /** @@ -180,10 +181,10 @@ export class FnType extends Type> { ); } - toCode(): string { - const ret = this._call.returns?.toCode() ?? 'void'; - return this.docsPrefix() - + `${renderGenerics(this.generic)}${renderCallTypes(this._call.types)}(${formatParams(this._call.args)}): ${ret}`; + toCode(_registry?: Registry, options?: CodeOptions): string { + const ret = this._call.returns?.toCode(undefined, options) ?? 'void'; + return this.docsPrefix(options) + + `${renderGenerics(this.generic, options)}${renderCallTypes(this._call.types, options)}(${formatParams(this._call.args, options)}): ${ret}`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/iface.ts b/packages/gin/src/types/iface.ts index df3ba16..edf4a0f 100644 --- a/packages/gin/src/types/iface.ts +++ b/packages/gin/src/types/iface.ts @@ -1,5 +1,6 @@ import type { TypeScope } from '../type-scope'; import type { TypeDef } from '../schema'; +import type { Registry } from '../registry'; import { Value } from '../value'; import { Call, @@ -9,10 +10,11 @@ import { type PropSpec, type Rnd, Type, + joinAuto, } from '../type'; import { decodeCall, decodeGetSet, decodeProps, encodeProps } from '../spec'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import { callDefSchema, getSetDefSchema, propDefSchema } from '../schemas'; /** @@ -201,24 +203,25 @@ export class IfaceType extends Type> { return new IfaceType(this.registry, { props: p, get: this._get, call: this._call }); } - toCode(): string { + toCode(_registry?: Registry, options?: CodeOptions): string { + const includeComments = options?.includeComments !== false; const parts: string[] = []; for (const [name, prop] of Object.entries(this._props)) { const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; const label = optional ? `${name}?` : name; - const propDocs = prop.docs ? `/* ${prop.docs} */ ` : ''; - parts.push(`${propDocs}${label}: ${t.toCode()}`); + const propDocs = prop.docs && includeComments ? `/* ${prop.docs} */ ` : ''; + parts.push(`${propDocs}${label}: ${t.toCode(undefined, options)}`); } if (this._get) { - parts.push(`[key: ${this._get.key.toCode()}]: ${this._get.value.toCode()}`); + parts.push(`[key: ${this._get.key.toCode(undefined, options)}]: ${this._get.value.toCode(undefined, options)}`); } if (this._call) { - const ret = this._call.returns?.toCode() ?? 'void'; - parts.push(`(${this._call.args.toCode()}): ${ret}`); + const ret = this._call.returns?.toCode(undefined, options) ?? 'void'; + parts.push(`(${this._call.args.toCode(undefined, options)}): ${ret}`); } - const body = parts.length === 0 ? 'iface' : `iface{${parts.join(', ')}}`; - return this.docsPrefix() + body; + const body = parts.length === 0 ? 'iface' : `iface{${joinAuto(parts)}}`; + return this.docsPrefix(options) + body; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/list.ts b/packages/gin/src/types/list.ts index 60219da..c306003 100644 --- a/packages/gin/src/types/list.ts +++ b/packages/gin/src/types/list.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { ListOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue } from '../json-type'; @@ -223,8 +224,12 @@ export class ListType extends Type { return new ListType(this.registry, this.item.clone() as Type, { ...this.options }); } - toCode(): string { - return this.docsPrefix() + `list<${this.item.toCode()}>` + optionsCode(this.options); + toCode(_registry?: Registry, options?: CodeOptions): string { + // `minLength=0` is a no-op; skip. `maxLength` only renders when + // explicitly set. + return this.docsPrefix(options) + `list<${this.item.toCode(undefined, options)}>` + optionsCode(this.options, { + minLength: 0, + }); } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/literal.ts b/packages/gin/src/types/literal.ts index 9f43d72..2c89e7f 100644 --- a/packages/gin/src/types/literal.ts +++ b/packages/gin/src/types/literal.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type PropSpec, type Rnd, Type, optionsCode } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -140,8 +141,8 @@ export class LiteralType extends Type> { return new LiteralType(this.registry, this.inner.clone() as Type, this.literal); } - toCode(): string { - return this.docsPrefix() + `literal<${this.inner.toCode()}>` + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `literal<${this.inner.toCode(undefined, options)}>` + optionsCode({ value: this.literal }); } diff --git a/packages/gin/src/types/map.ts b/packages/gin/src/types/map.ts index 2a29569..7be4db9 100644 --- a/packages/gin/src/types/map.ts +++ b/packages/gin/src/types/map.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue } from '../json-type'; @@ -181,8 +182,8 @@ export class MapType extends Type, Record`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `map<${this.key.toCode(undefined, options)}, ${this.value.toCode(undefined, options)}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/not.ts b/packages/gin/src/types/not.ts index 6e515f3..5c1d9e5 100644 --- a/packages/gin/src/types/not.ts +++ b/packages/gin/src/types/not.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface NotOptions { @@ -116,7 +117,7 @@ export class NotType extends Type { return new NotType(this.registry, this.excluded.clone()); } - toCode(): string { return this.docsPrefix() + `not<${this.excluded.toCode()}>`; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + `not<${this.excluded.toCode(undefined, options)}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { const excluded = this.excluded.toValueSchema(opts); diff --git a/packages/gin/src/types/null.ts b/packages/gin/src/types/null.ts index f548cd9..a946a05 100644 --- a/packages/gin/src/types/null.ts +++ b/packages/gin/src/types/null.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -89,7 +90,7 @@ export class NullType extends Type> { return new NullType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'null'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'null'; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } diff --git a/packages/gin/src/types/nullable.ts b/packages/gin/src/types/nullable.ts index 0d13e4d..d5aff25 100644 --- a/packages/gin/src/types/nullable.ts +++ b/packages/gin/src/types/nullable.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, RuntimeOf } from '../json-type'; @@ -127,8 +128,8 @@ export class NullableType extends Type> return new NullableType(this.registry, this.inner.clone() as Type); } - toCode(): string { - return this.docsPrefix() + `nullable<${this.inner.toCode()}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `nullable<${this.inner.toCode(undefined, options)}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/num.ts b/packages/gin/src/types/num.ts index 2ab1b7a..72e0761 100644 --- a/packages/gin/src/types/num.ts +++ b/packages/gin/src/types/num.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { NumOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -233,7 +234,18 @@ export class NumType extends Type { return new NumType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'num' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { + // `minPrecision` / `maxPrecision` / `prefix` / `suffix` are + // display-only — skip when at their typical defaults so the + // type code stays focused on validation-relevant constraints + // (`min`, `max`, `whole`). + return this.docsPrefix(options) + 'num' + optionsCode(this.options, { + minPrecision: 1, + maxPrecision: 7, + prefix: '', + suffix: '', + }); + } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = this.options.whole ? z.number().int() : z.number(); diff --git a/packages/gin/src/types/obj.ts b/packages/gin/src/types/obj.ts index 7c6c248..f2c4166 100644 --- a/packages/gin/src/types/obj.ts +++ b/packages/gin/src/types/obj.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef, PropDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, Prop, type PropSpec, type Rnd, Type } from '../type'; import { decodeProps, encodeProps } from '../spec'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONOf, JSONValue, RuntimeOf } from '../json-type'; import { propDefSchema } from '../schemas'; @@ -15,7 +16,7 @@ import { propDefSchema } from '../schemas'; * exposed via props() directly. Any number of fields, typed per-name. */ export class ObjType> extends Type> { - static readonly NAME = 'object'; + static readonly NAME = 'obj'; /** obj's fields ARE its structure — props is natively consumed. */ static readonly consumes = ['props'] as const; readonly name = ObjType.NAME; @@ -32,7 +33,7 @@ export class ObjType> extends Type> extends Type(this.registry, cloned); } - toCode(): string { + toCode(_registry?: Registry, options?: CodeOptions): string { const entries = Object.entries(this.fields); - if (entries.length === 0) return this.docsPrefix() + 'obj'; + if (entries.length === 0) return this.docsPrefix(options) + 'obj'; + const includeComments = options?.includeComments !== false; const parts = entries.map(([name, prop]) => { const optional = prop.type.isOptional(); const t = optional ? prop.type.required() : prop.type; const label = optional ? `${name}?` : name; - const propDocs = prop.docs ? `/* ${prop.docs} */ ` : ''; - return `${propDocs}${label}: ${t.toCode()}`; + const propDocs = prop.docs && includeComments ? `/* ${prop.docs} */ ` : ''; + return `${propDocs}${label}: ${t.toCode(undefined, options)}`; }); - return this.docsPrefix() + `obj{${parts.join(', ')}}`; + return this.docsPrefix(options) + `obj{${parts.join(', ')}}`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { @@ -249,7 +251,7 @@ export class ObjType> extends Type extends Type); } - toCode(): string { - return this.docsPrefix() + `optional<${this.inner.toCode()}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `optional<${this.inner.toCode(undefined, options)}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/or.ts b/packages/gin/src/types/or.ts index 28412d9..13c4bb4 100644 --- a/packages/gin/src/types/or.ts +++ b/packages/gin/src/types/or.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { Call, type CompatOptions, GetSet, type Prop, type PropSpec, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; export interface OrOptions { @@ -176,8 +177,8 @@ export class OrType extends Type { return new OrType(this.registry, this.variants.map((v) => v.clone())); } - toCode(): string { - return this.docsPrefix() + `or<${this.variants.map((v) => v.toCode()).join(', ')}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `or<${this.variants.map((v) => v.toCode(undefined, options)).join(', ')}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/text.ts b/packages/gin/src/types/text.ts index 0e8b237..68ba99f 100644 --- a/packages/gin/src/types/text.ts +++ b/packages/gin/src/types/text.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { TextOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -207,7 +208,7 @@ export class TextType extends Type { return new TextType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'text' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'text' + optionsCode(this.options); } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { let s = z.string(); diff --git a/packages/gin/src/types/timestamp.ts b/packages/gin/src/types/timestamp.ts index dc7ea78..663fd93 100644 --- a/packages/gin/src/types/timestamp.ts +++ b/packages/gin/src/types/timestamp.ts @@ -1,11 +1,12 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type, optionsCode } from '../type'; import type { TimestampOptions } from '../builder'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -122,7 +123,7 @@ export class TimestampType extends Type { return new TimestampType(this.registry, { ...this.options }); } - toCode(): string { return this.docsPrefix() + 'timestamp' + optionsCode(this.options); } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'timestamp' + optionsCode(this.options); } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { // Dump form is ISO 8601 datetime with a REQUIRED time component: diff --git a/packages/gin/src/types/tuple.ts b/packages/gin/src/types/tuple.ts index 1b40110..c4be4fb 100644 --- a/packages/gin/src/types/tuple.ts +++ b/packages/gin/src/types/tuple.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { PathStepDef, TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, GetSet, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import type { JSONValue } from '../json-type'; @@ -167,8 +168,8 @@ export class TupleType extends Type<[any, ...any[]], TupleOptions> { return new TupleType(this.registry, this.elements.map((e) => e.clone())); } - toCode(): string { - return this.docsPrefix() + `tuple<${this.elements.map((e) => e.toCode()).join(', ')}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `tuple<${this.elements.map((e) => e.toCode(undefined, options)).join(', ')}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/typ.ts b/packages/gin/src/types/typ.ts index bfeb472..9f15296 100644 --- a/packages/gin/src/types/typ.ts +++ b/packages/gin/src/types/typ.ts @@ -1,7 +1,8 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import { z } from 'zod'; import type { TypeDef } from '../schema'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { Value } from '../value'; import { extensionSchemaNarrowed } from '../schemas'; @@ -135,8 +136,8 @@ export class TypType extends Type> { return new TypType(this.registry, this.constraint.clone() as Type); } - toCode(): string { - return this.docsPrefix() + `typ<${this.constraint.toCode()}>`; + toCode(_registry?: Registry, options?: CodeOptions): string { + return this.docsPrefix(options) + `typ<${this.constraint.toCode(undefined, options)}>`; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { diff --git a/packages/gin/src/types/void.ts b/packages/gin/src/types/void.ts index 8ab9673..b424d60 100644 --- a/packages/gin/src/types/void.ts +++ b/packages/gin/src/types/void.ts @@ -1,10 +1,11 @@ import type { TypeScope } from '../type-scope'; +import type { Registry } from '../registry'; import type { TypeDef } from '../schema'; import { Value } from '../value'; import { type CompatOptions, type Prop, type Rnd, Type } from '../type'; import { TypeError } from '../problem'; import { z } from 'zod'; -import type { SchemaOptions, ValueSchemaOptions } from '../node'; +import type { CodeOptions, SchemaOptions, ValueSchemaOptions } from '../node'; /** @@ -88,7 +89,7 @@ export class VoidType extends Type> { return new VoidType(this.registry, {}); } - toCode(): string { return this.docsPrefix() + 'void'; } + toCode(_registry?: Registry, options?: CodeOptions): string { return this.docsPrefix(options) + 'void'; } toValueSchema(opts?: ValueSchemaOptions): z.ZodTypeAny { return this.describeType(z.null(), opts); } diff --git a/packages/ginny/README.md b/packages/ginny/README.md index 851084b..257222d 100644 --- a/packages/ginny/README.md +++ b/packages/ginny/README.md @@ -70,7 +70,7 @@ ginny is a small council of sub-agents, each specialized: ┌────────────────┬──────┴──────┬────────────────┐ ▼ ▼ ▼ ▼ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │ architect │ │ engineer │ │ dba │ │ researcher │ + │ architect │ │ designer │ │ dba │ │ researcher │ │ (types) │ │ (fns) │ │ (vars) │ │ (web search │ │ │ │ │ │ │ │ + pages) │ └─────────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘ @@ -86,7 +86,7 @@ ginny is a small council of sub-agents, each specialized: - **architect** — searches `./types/*.json` by keyword (top-10 above a configurable threshold, or all entries below); returns existing types or designs new ones. -- **engineer** — same pattern over `./fns/*.json`; can recursively +- **designer** — same pattern over `./fns/*.json`; can recursively spin up the programmer to implement a brand-new function body. - **dba** — same pattern over `./vars/*.json` (typed named values the user or agent can read/write). diff --git a/packages/ginny/src/config.ts b/packages/ginny/src/config.ts index df84a10..0762db7 100644 --- a/packages/ginny/src/config.ts +++ b/packages/ginny/src/config.ts @@ -14,6 +14,7 @@ export interface GinConfig { GIN_MODEL?: string; GIN_PROVIDER?: string; GIN_SEARCH_THRESHOLD?: number; + GIN_TOOL_ITERATIONS?: number; } const TEMPLATE: GinConfig = { @@ -24,6 +25,7 @@ const TEMPLATE: GinConfig = { GIN_MODEL: '', GIN_PROVIDER: '', GIN_SEARCH_THRESHOLD: 20, + GIN_TOOL_ITERATIONS: 100, }; function ensureGitignore(cwd: string): void { @@ -73,6 +75,7 @@ export function loadConfig(cwd: string): void { console.log(' GIN_PROVIDER — optional, preferred provider (openai | openrouter | aws)'); console.log(' GIN_MODEL — optional, specific model id'); console.log(' GIN_SEARCH_THRESHOLD — optional, corpus size below which search returns all (default 20)'); + console.log(' GIN_TOOL_ITERATIONS — optional, max tool-call iterations per prompt run (default 100)'); console.log(''); console.log('Environment variables still win over config.json values.'); process.exit(0); diff --git a/packages/ginny/src/context.ts b/packages/ginny/src/context.ts index 9025dd7..11654ee 100644 --- a/packages/ginny/src/context.ts +++ b/packages/ginny/src/context.ts @@ -23,7 +23,7 @@ export interface Ctx { ask?: (question: string, signal?: AbortSignal) => Promise; /** * How many programmer invocations deep we are. Top-level (REPL) is 0; - * each `engineer.create_new_fn` increments by 1 before invoking + * each `designer.create_new_fn` increments by 1 before invoking * programmer recursively. The `find_or_create_functions` tool gates * its `applicable` on this so a recursive programmer at the cap * can't keep delegating function creation back to itself — it has to @@ -33,7 +33,7 @@ export interface Ctx { /** * The user's original top-level request, captured by the entry point * before launching the depth-0 programmer. Plumbed through every - * recursive engineer/programmer pair so a deep programmer can render + * recursive designer/programmer pair so a deep programmer can render * "what is this work ultimately for" alongside its own immediate * task. Empty for non-interactive entry points that didn't bother to * set it. @@ -41,25 +41,25 @@ export interface Ctx { originalRequest?: string; /** * Call-chain ancestry for recursive programmers, oldest → newest. - * Each entry is a function the engineer was asked to create at one + * Each entry is a function the designer was asked to create at one * level of nesting. Empty at depth 0; appended once per - * `engineer.create_new_fn` before spawning the inner programmer. A + * `designer.create_new_fn` before spawning the inner programmer. A * programmer at depth N reads the chain to understand which caller * needs its function and why — so it can stay scoped to that need. */ programmerChain?: ProgrammerChainEntry[]; /** - * Set by `engineer.create_new_fn` before invoking the inner programmer. + * Set by `designer.create_new_fn` before invoking the inner programmer. * Tells `test()` how to wrap raw scope args into typed `Value`s and * tells `finish()` what signature to use when persisting the draft — - * so the saved fn matches what the engineer designed instead of being + * so the saved fn matches what the designer designed instead of being * `(): or` (an inference of the body's static type). * * `argsType` is intentionally `ObjType`, not the generic `Type`: a * gin function's arguments are always an obj whose props ARE the * parameter list. Typing it concretely lets downstream tools read * `argsType.fields` and call `argsType.parse(rawArgs)` without - * narrowing checks, and forces `engineer.create_new_fn` to validate + * narrowing checks, and forces `designer.create_new_fn` to validate * the input up front. */ targetFn?: { @@ -73,7 +73,7 @@ export interface Ctx { returnsType: Type; /** * Optional source forms for round-trip preservation when the - * engineer declared `call.types` aliases. `finish()` writes these + * designer declared `call.types` aliases. `finish()` writes these * back verbatim so the saved fn keeps its compact shape; without * them, `argsType.toJSON()` would emit the verbose inlined form. */ @@ -84,13 +84,13 @@ export interface Ctx { } /** Hard cap on programmer recursion. With 0-indexed depth, programmers - * at depth < MAX_PROGRAMMER_DEPTH - 1 can delegate to the engineer to + * at depth < MAX_PROGRAMMER_DEPTH - 1 can delegate to the designer to * create more programmers; the deepest one cannot. Set to 3 → max 3 * programmers in the stack. */ export const MAX_PROGRAMMER_DEPTH = 3; /** - * One step in the programmer call-chain — recorded by the engineer at + * One step in the programmer call-chain — recorded by the designer at * each `create_new_fn`. The chain lets a deep programmer reason about * which parent function depends on its output and what the original * user request was, instead of seeing only its own isolated signature. @@ -102,7 +102,7 @@ export interface ProgrammerChainEntry { argsCode: string; /** `returnsType.toCode()` — human-readable return shape. */ returnsCode: string; - /** The engineer's `description` input — what this function should do. */ + /** The designer's `description` input — what this function should do. */ description: string; } diff --git a/packages/ginny/src/event-display.ts b/packages/ginny/src/event-display.ts index a710f8d..968969b 100644 --- a/packages/ginny/src/event-display.ts +++ b/packages/ginny/src/event-display.ts @@ -24,10 +24,13 @@ const PREVIEW_MAX = 120; function preview(value: unknown): string { let s: string; - try { - s = typeof value === 'string' ? value : JSON.stringify(value); - } catch { - s = String(value); + if (value instanceof Error) { + // `JSON.stringify(new Error())` is `{}` — surface .message instead. + s = value.message || String(value); + } else if (typeof value === 'string') { + s = value; + } else { + try { s = JSON.stringify(value); } catch { s = String(value); } } if (s == null) return ''; s = s.replace(/\s+/g, ' '); @@ -91,7 +94,9 @@ export class EventDisplay { case 'refusal': { this.breakIfText(); - process.stderr.write(`${this.c(RED, `refusal: ${event.content ?? ''}`)}\n`); + const line = `refusal: ${preview(event.content ?? '')}`; + process.stderr.write(`${this.c(RED, line)}\n`); + logger.log(`refusal: ${event.content ?? ''}`); this.last = 'text'; break; } @@ -119,9 +124,11 @@ export class EventDisplay { case 'toolError': { const started = this.toolStarts.get(event.args); const elapsed = started ? Date.now() - started : 0; - const line = `✗ ${event.tool.name} (${elapsed}ms): ${event.error}`; + // Cap the on-screen error: zod / aggregate errors can run hundreds + // of lines and bury the live view. Full text still goes to ginny.log. + const line = `✗ ${event.tool.name} (${elapsed}ms): ${preview(event.error)}`; process.stderr.write(`${this.c(RED, line)}\n`); - logger.log(line); + logger.log(`✗ ${event.tool.name} (${elapsed}ms): ${event.error}`); this.last = 'tool'; break; } @@ -135,9 +142,11 @@ export class EventDisplay { } case 'textReset': { - const line = `(reset: ${event.reason ?? 'unspecified'})`; + // The reason can be a multi-line forget/output retry message + // (e.g. zod validation). Show a one-liner; full text → ginny.log. + const line = `(reset: ${preview(event.reason ?? 'unspecified')})`; process.stderr.write(`${this.c(DIM, line)}\n`); - logger.log(line); + logger.log(`(reset: ${event.reason ?? 'unspecified'})`); break; } } diff --git a/packages/ginny/src/index.ts b/packages/ginny/src/index.ts index 418ff6f..3f664e7 100644 --- a/packages/ginny/src/index.ts +++ b/packages/ginny/src/index.ts @@ -3,6 +3,7 @@ import * as readline from 'readline'; import type { Message } from '@aeye/core'; import { programmer } from './prompts/programmer'; import { EventDisplay } from './event-display'; +import { logger } from './logger'; /** * Single readline interface used for both the REPL prompt loop AND for @@ -114,7 +115,7 @@ async function runRequest(request: string): Promise { messages: history, ask: askUser, // Top-level request — propagates down through every recursive - // engineer/programmer pair so deep programmers know what the + // designer/programmer pair so deep programmers know what the // user originally asked for, not just their immediate task. originalRequest: request, }, @@ -143,8 +144,16 @@ async function runRequest(request: string): Promise { if (abort.signal.aborted) { process.stderr.write('\n(cancelled)\n'); } else { - console.error('\nError:', err.message ?? String(e)); - if (err.stack) console.error(err.stack); + // Keep the on-screen error short — zod / aggregate errors can + // dump hundreds of lines that bury the prompt. Full message and + // stack go to ginny.log for post-mortem. + const raw = err.message ?? String(e); + const oneLiner = raw.replace(/\s+/g, ' ').trim(); + const short = oneLiner.length > 200 ? `${oneLiner.slice(0, 200)}…` : oneLiner; + console.error(`\nError: ${short}`); + console.error('(see ginny.log for full details)'); + logger.log(`Error: ${raw}`); + if (err.stack) logger.log(err.stack); } } finally { process.off('SIGINT', onSigint); @@ -178,7 +187,13 @@ async function main() { prompt(); } -main().catch((e) => { - console.error(e); +main().catch((e: unknown) => { + const err = e as { message?: string; stack?: string }; + const raw = err.message ?? String(e); + const oneLiner = raw.replace(/\s+/g, ' ').trim(); + const short = oneLiner.length > 200 ? `${oneLiner.slice(0, 200)}…` : oneLiner; + console.error(`Error: ${short}`); + logger.log(`Error: ${raw}`); + if (err.stack) logger.log(err.stack); process.exit(1); }); diff --git a/packages/ginny/src/model-selection.ts b/packages/ginny/src/model-selection.ts index c4e4035..e4b55ee 100644 --- a/packages/ginny/src/model-selection.ts +++ b/packages/ginny/src/model-selection.ts @@ -9,7 +9,7 @@ * GIN_PROGRAMMER_MODEL=gpt-4o * GIN_RESEARCHER_MODEL=gpt-4o-mini * GIN_ARCHITECT_MODEL=gpt-4o-mini # designs / picks gin types - * GIN_ENGINEER_MODEL=gpt-4o # designs reusable gin functions + * GIN_DESIGNER_MODEL=gpt-4o # designs reusable gin functions * GIN_DBA_MODEL=gpt-4o-mini # curates named typed vars (vars.*) * GIN_LLM_MODEL=gpt-4o-mini # used by the fns.llm native inside programs * GIN_MODEL=gpt-4o-mini # fallback for any key above @@ -18,7 +18,7 @@ export const MODEL_KEYS = [ 'programmer', 'researcher', 'architect', - 'engineer', + 'designer', 'dba', 'llm', ] as const; @@ -39,3 +39,15 @@ export function modelFor(key: ModelKey): { model: { id: string } } | undefined { const id = (specific && specific.trim()) || (fallback && fallback.trim()); return id ? { model: { id } } : undefined; } + +/** + * Tool-iteration cap per prompt run. Resolves from `GIN_TOOL_ITERATIONS` + * (configured in `config.json` or the environment); falls back to 100 + * when unset / unparseable so prompts have headroom for deep tasks. + */ +export function toolIterationsConfig(): number { + const raw = process.env['GIN_TOOL_ITERATIONS']; + if (!raw) return 100; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : 100; +} diff --git a/packages/ginny/src/progress.ts b/packages/ginny/src/progress.ts index b6a8cbb..8a3baeb 100644 --- a/packages/ginny/src/progress.ts +++ b/packages/ginny/src/progress.ts @@ -33,10 +33,13 @@ const c = (code: string, text: string): string => function preview(value: unknown): string { let s: string; - try { - s = typeof value === 'string' ? value : JSON.stringify(value); - } catch { - s = String(value); + if (value instanceof Error) { + // `JSON.stringify(new Error())` is `{}` — surface .message instead. + s = value.message || String(value); + } else if (typeof value === 'string') { + s = value; + } else { + try { s = JSON.stringify(value); } catch { s = String(value); } } if (!s) return ''; s = s.replace(/\s+/g, ' '); @@ -103,9 +106,12 @@ export async function runSubagent< case 'toolError': { const t = toolStarts.get(event.args); const elapsed = t ? Date.now() - t : 0; - const line = ` ✗ ${event.tool.name} (${elapsed}ms): ${event.error}`; + // Cap the on-screen error to one line — zod / aggregate + // errors easily run 100+ lines and bury the timeline. Full + // text → ginny.log. + const line = ` ✗ ${event.tool.name} (${elapsed}ms): ${preview(event.error)}`; process.stderr.write(`${c(RED, line)}\n`); - logger.log(line.trim()); + logger.log(`✗ ${event.tool.name} (${elapsed}ms): ${event.error}`); break; } case 'complete': { diff --git a/packages/ginny/src/prompts/architect.ts b/packages/ginny/src/prompts/architect.ts index d839109..4bf6c2d 100644 --- a/packages/ginny/src/prompts/architect.ts +++ b/packages/ginny/src/prompts/architect.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { buildSchemas } from '@aeye/gin'; import type { TypeDef } from '@aeye/gin'; import { ai } from '../ai'; -import { modelFor } from '../model-selection'; +import { modelFor, toolIterationsConfig } from '../model-selection'; import { ask } from '../tools/ask'; const searchTypes = ai.tool({ @@ -52,7 +52,7 @@ Respond with valid JSON matching the output schema. Request: {{description}}`, input: (input: { description: string }) => ({ description: input.description }), tools: [searchTypes, getType, ask], - toolIterations: 5, + toolIterations: toolIterationsConfig(), // Sub-prompt: takes its task via {{description}}, not via inherited // messages. Skipping the parent's history avoids dragging in the // in-flight tool_calls assistant message that triggered the diff --git a/packages/ginny/src/prompts/dba.ts b/packages/ginny/src/prompts/dba.ts index 63f0226..006d785 100644 --- a/packages/ginny/src/prompts/dba.ts +++ b/packages/ginny/src/prompts/dba.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { buildSchemas } from '@aeye/gin'; import type { TypeDef } from '@aeye/gin'; import { ai } from '../ai'; -import { modelFor } from '../model-selection'; +import { modelFor, toolIterationsConfig } from '../model-selection'; import { refreshVarsGlobal } from '../vars-global'; import { ask } from '../tools/ask'; @@ -88,7 +88,7 @@ so the programmer can return a clear "set vars.X then re-run" message. Request: {{description}}`, input: (input: { description: string }) => ({ description: input.description }), tools: [searchVars, getVar, createVar, ask], - toolIterations: 5, + toolIterations: toolIterationsConfig(), excludeMessages: true, schema: z.object({ use: z.array(z.string()).default([]).describe('Names of existing vars to use'), diff --git a/packages/ginny/src/prompts/engineer.ts b/packages/ginny/src/prompts/designer.ts similarity index 84% rename from packages/ginny/src/prompts/engineer.ts rename to packages/ginny/src/prompts/designer.ts index cd3fd11..cda6344 100644 --- a/packages/ginny/src/prompts/engineer.ts +++ b/packages/ginny/src/prompts/designer.ts @@ -1,35 +1,22 @@ import { z } from 'zod'; -import { ToolInterrupt, type Message } from '@aeye/core'; +import type { Message } from '@aeye/core'; import { buildSchemas, ObjType } from '@aeye/gin'; import type { TypeDef, Type } from '@aeye/gin'; import { ai } from '../ai'; -import { modelFor } from '../model-selection'; +import { modelFor, toolIterationsConfig } from '../model-selection'; import { ask } from '../tools/ask'; +import { printFn } from '../tools/print-fn'; +import { searchFns } from '../tools/search-fns'; import { runSubagent } from '../progress'; import { MAX_PROGRAMMER_DEPTH, type ProgrammerChainEntry } from '../context'; import { createRunState } from '../run-state'; -// `programmer` and `engineer` form a circular import (programmer ↔ -// findOrCreateFunctions → engineer → createNewFn → programmer). The +// `programmer` and `designer` form a circular import (programmer ↔ +// findOrCreateFunctions → designer → createNewFn → programmer). The // reference here is only used inside `createNewFn`'s `call` async fn, // so by call-time both modules have finished initializing — ESM live // bindings make this safe. import { programmer } from './programmer'; -const searchFns = ai.tool({ - name: 'search_fns', - description: 'Search existing functions by keywords.', - instructions: 'Search the function catalog.', - schema: z.object({ - keywords: z.array(z.string()), - limit: z.number().optional().default(10), - }), - call: async (input: { keywords: string[]; limit?: number }, _refs, ctx) => { - const results = ctx.store.searchFns({ keywords: input.keywords, limit: input.limit }); - if (results.length === 0) return 'No matching functions found.'; - return results.map((r) => `${r.name}: ${r.summary}`).join('\n'); - }, -}); - const getFn = ai.tool({ name: 'get_fn', description: 'Get the full signature of a function by name.', @@ -86,7 +73,7 @@ const createNewFn = ai.tool({ }); }, // Defensive — the deepest programmer is supposed to write inline, but - // also block engineer.createNewFn at the cap in case a different path + // also block designer.createNewFn at the cap in case a different path // got us here. applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, call: async ( @@ -100,8 +87,8 @@ const createNewFn = ai.tool({ _refs, ctx, ) => { - // Parse the engineer-supplied signature into runtime Types. When - // the engineer declared `types` aliases, args/returns may + // Parse the designer-supplied signature into runtime Types. When + // the designer declared `types` aliases, args/returns may // reference them — we resolve those by parsing through a synthetic // FnType TypeDef. `decodeCall` builds a LocalScope binding each // alias sequentially, so bare `{name: ""}` references @@ -127,10 +114,16 @@ const createNewFn = ai.tool({ if (!parsedCall.returns) throw new Error('returns is required'); returnsType = parsedCall.returns; } catch (e: unknown) { - throw new ToolInterrupt( - `Could not parse signature for '${input.name}': ${e instanceof Error ? e.message : String(e)}. ` + - `args must be an obj type whose props are the function's parameters — e.g. \`{ name: "obj", props: { n: { type: { name: "num" } } } }\`. ` + - `If you declared \`types\` aliases, ensure each is declared before it's referenced and that referenced names are bare \`{name: ""}\`.`, + // Return-as-string (not throw) so the designer's LLM sees the + // failure reason in the tool result. `ToolInterrupt` would be + // captured by `@aeye/core` as a suspension event with an empty + // tool result — the LLM gets nothing useful and silently emits + // `created: []`. + return ( + `// FAILED: could not parse signature for '${input.name}': ${e instanceof Error ? e.message : String(e)}. ` + + `args must be an obj type whose props are the function's parameters — e.g. \`{ name: "obj", props: { n: { type: { name: "num" } } } }\`. ` + + `If you declared \`types\` aliases, ensure each is declared before it's referenced and that referenced names are bare \`{name: ""}\`. ` + + `Do NOT include '${input.name}' in your final \`created\` list.` ); } @@ -145,7 +138,7 @@ const createNewFn = ai.tool({ : paramNames.map((p) => `\`${p}\``).join(', '); // Build the chain ancestry for the inner programmer. Each prior - // engineer call appended its `create_new_fn` input as one entry. + // designer call appended its `create_new_fn` input as one entry. // The current call appends itself BEFORE the inner programmer is // launched so the deepest entry is "you are here". const parentChain = ctx.programmerChain ?? []; @@ -191,7 +184,7 @@ const createNewFn = ai.tool({ // message so it has the full signature in scope and doesn't try to // delegate back to find_or_create_functions / create_new_fn. const request = [ - `You ARE the writer of this gin function. The engineer has already designed the signature; your job is to author the body. Do NOT call find_or_create_functions or delegate elsewhere.`, + `You ARE the writer of this gin function. The designer has already designed the signature; your job is to author the body. Do NOT call find_or_create_functions or delegate elsewhere.`, ``, `Function name: ${input.name}`, `Args type: ${argsCode}`, @@ -225,7 +218,7 @@ const createNewFn = ai.tool({ ``, `1. \`write({ program: })\`.`, `2. \`test({ args: { ${paramNames.map((p) => `${p}: `).join(', ')} } })\` — concrete sample values matching the args type; the args schema is auto-built from the function's args type.`, - `3. \`finish({ saveAs: '${input.name}' })\` once the test passes — this persists the body with the engineer-designed signature.`, + `3. \`finish({ saveAs: '${input.name}' })\` once the test passes — this persists the body with the designer-designed signature.`, ].join('\n'); // Fresh sub-conversation, fresh runState we can read after the run @@ -261,34 +254,34 @@ const createNewFn = ai.tool({ ); // Verify the inner programmer actually produced a working draft. - // Throw `ToolInterrupt` rather than returning a string — the AI - // runtime turns that into an error event, so the engineer's - // structured-output stage can't quietly include this name in - // `created` when the file was never written. (Returning a "did not - // succeed" string still counts as a successful tool call to the - // engineer, which led to ghost entries.) + // Return the failure as a string (not a `ToolInterrupt` throw) so + // the designer's LLM sees WHY in the tool result and can decide to + // try a different signature, give up cleanly, or relay the reason. + // Strings starting with `// FAILED:` are the convention the + // designer's prompt teaches to keep the failed name out of the + // final `created` list. if (!innerRunState.lastTest?.success) { const why = innerRunState.lastTest?.error ?? 'no successful test was recorded'; - throw new ToolInterrupt( - `Function '${input.name}' was NOT created — programmer did not reach a passing test (${why}). ` + - `Refine the description / signature and try again, or do not include this name in your final \`created\` list.`, + return ( + `// FAILED: function '${input.name}' was NOT created — programmer did not reach a passing test (${why}). ` + + `Refine the description / signature and try again, or do not include this name in your final \`created\` list.` ); } if (!ctx.loadedFns.has(input.name)) { - throw new ToolInterrupt( - `Function '${input.name}' was NOT saved — programmer reached a passing test but didn't call finish({ saveAs: '${input.name}' }). ` + - `Do not include this name in your final \`created\` list.`, + return ( + `// FAILED: function '${input.name}' was NOT saved — programmer reached a passing test but didn't call finish({ saveAs: '${input.name}' }). ` + + `Do not include this name in your final \`created\` list.` ); } return `Function '${input.name}' created (${argsCode} → ${returnsCode}). It is now safe to include '${input.name}' in your final \`created\` list.`; }, }); -export const engineer = ai.prompt({ - name: 'engineer', +export const designer = ai.prompt({ + name: 'designer', description: 'Design or reuse gin functions — the reusable building blocks of programs.', - metadata: modelFor('engineer') as any, - content: `You are the engineer — responsible for designing and curating + metadata: modelFor('designer') as any, + content: `You are the designer — responsible for designing and curating reusable gin functions. Find an existing function that matches the request or spin up a programmer to author a new one. @@ -325,19 +318,19 @@ function. Request: {{description}}`, input: (input: { description: string }) => ({ description: input.description }), - tools: [searchFns, getFn, createNewFn, ask], - toolIterations: 8, + tools: [searchFns, getFn, printFn, createNewFn, ask], + toolIterations: toolIterationsConfig(), excludeMessages: true, schema: z.object({ use: z.array(z.string()).default([]).describe('Names of existing functions confirmed via get_fn / search_fns.'), created: z.array(z.string()).default([]).describe('Names of functions create_new_fn successfully wrote to disk this session. Do NOT include names where create_new_fn errored.'), }), - // Round-trip the engineer's structured output against disk before + // Round-trip the designer's structured output against disk before // returning it. Anything in `use` / `created` must actually be - // readable via `store.readFn` — otherwise the engineer is + // readable via `store.readFn` — otherwise the designer is // hallucinating and the programmer downstream would hit ENOENT when // it tries to load the fn. Throwing here forces the prompt loop to - // re-prompt the engineer with the validation error so it can fix the + // re-prompt the designer with the validation error so it can fix the // arrays. validate: (output, ctx) => { const { use = [], created = [] } = output; diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index 71a8149..716b446 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -1,6 +1,6 @@ import type { Registry, Type, TypeDef } from '@aeye/gin'; import { ai } from '../ai'; -import { modelFor } from '../model-selection'; +import { modelFor, toolIterationsConfig } from '../model-selection'; import { write } from '../tools/write'; import { test } from '../tools/test'; import { finish } from '../tools/finish'; @@ -9,6 +9,9 @@ import { findOrCreateTypes } from '../tools/find-or-create-types'; import { findOrCreateFunctions } from '../tools/find-or-create-fns'; import { findOrCreateVars } from '../tools/find-or-create-vars'; import { ask } from '../tools/ask'; +import { printFn } from '../tools/print-fn'; +import { searchFns } from '../tools/search-fns'; +import { searchVars } from '../tools/search-vars'; /** * Rebuild a class's canonical instance with `generic` type-parameter @@ -312,7 +315,7 @@ as ginny in all self-referential responses. You orchestrate four specialist sub-agents on demand: - **architect** — designs or picks gin types (\`find_or_create_types\`) -- **engineer** — writes reusable gin functions (\`find_or_create_functions\`) +- **designer** — writes reusable gin functions (\`find_or_create_functions\`) - **dba** — curates the \`vars.*\` catalog (\`find_or_create_vars\`) - **researcher** — answers factual questions from the web (\`research\`) @@ -486,13 +489,13 @@ body. Examples: When you delegate to \`find_or_create_functions\`, spell out which inputs are user-supplied (parameters) versus fixed in the description. -The engineer uses your description verbatim to design the signature. +The designer uses your description verbatim to design the signature. ## When \`find_or_create_functions\` fails If \`find_or_create_functions\` returns a message starting with \`// FAILED\` (or otherwise indicates no functions were loaded), it -means the engineer could not produce the function — typically because +means the designer could not produce the function — typically because the inner programmer never reached a passing test, or because no existing saved fn matched the keywords. @@ -500,7 +503,7 @@ When this happens: - Do NOT inline-define the missing function (no \`define myFn = lambda(...)\` as part of your draft). Inline-defining a recursive / loop-heavy function in gin without going through the - engineer's iteration is fragile and almost always produces invalid + designer's iteration is fragile and almost always produces invalid programs. - Respond to the user that the function couldn't be created, explain briefly what likely went wrong, and ask whether they want to: @@ -509,6 +512,83 @@ When this happens: (c) try a different approach altogether. - Then stop. Do not call write / test for an inline workaround. +## Use the aliases you declare + +If you put an entry in a fn's \`call.types\` map (or any other place +that accepts inline aliases), USE that alias name in \`args\`, +\`returns\`, \`throws\`, and inside the body — that's the entire point. +Declaring \`{ counter: { name: "num", options: {whole:true, min:1} } }\` +and then writing \`args: { name: "obj", props: { n: { type: { name: "num", options: {...} } } } }\` +with the full options block inline is wasted effort and bloats the +saved fn. + +When \`print_fn\` renders the saved fn it shows aliases as \`type + = ...;\` lines at the top of the body — exactly like a +TypeScript fn declaring local type aliases before the implementation. +The body should reference the aliases by bare name, e.g.: + +\`\`\` +fn computePrimeFactors(n: positiveInt): list { + type positiveInt = num{whole=true, min=1}; + + const acc: list = []; + ... +} +\`\`\` + +Pattern to follow: +1. Identify shapes that repeat in the signature OR the body — same + constrained \`num\`, same struct, same \`list\`, etc. +2. Declare each shape ONCE in \`call.types\` with a descriptive name + (\`positiveInt\`, \`Invoice\`, \`MoneyAmount\`). +3. Reference the alias as a bare \`{name: ""}\` everywhere it + appears in args / returns / throws / call.get / call.set. +4. Inside the body, when you author a \`new\` expr or a type + annotation on a \`define\`, also use the alias name — not the + inlined options block. + +If a type appears only ONCE in the whole signature and body, don't +bother aliasing it — declare it inline and move on. + +## Comments — DEFAULT IS NONE + +\`comment\` on an ExprDef renders inline in every \`toCode\` output. +**MOST EXPRESSIONS SHOULD HAVE NO COMMENT.** Annotating every node turns +a 5-line program into a 50-line wall of redundant prose. The rendered +code itself reads cleanly; descriptive identifiers and gin's structure +already convey intent. + +Hard rules — pattern-match against these BEFORE adding any \`comment\`: + +- ❌ \`{ kind:'get', path:[{prop:'args'},{prop:'text'}], comment:'Get the input text' }\` — the path IS \`args.text\`. +- ❌ \`{ kind:'new', type:{name:'num'}, value:0, comment:'the number zero' }\` — \`0\` is \`0\`. +- ❌ \`{ kind:'new', type:{name:'text'}, value:'neutral', comment:'Default to neutral' }\` — the literal IS \`"neutral"\`. +- ❌ \`{ kind:'flow', action:'return', value:..., comment:'Return the result' }\` — \`return\` already says it. +- ❌ Calls to a clearly-named fn like \`fns.llm({...})\` with \`comment:'Call the LLM'\` — the call site says it. +- ❌ Repeating a type's purpose at every reference (\`/* enum of valid sentiments */\` on every \`SentimentResult\`). + +Allowed comments — RARE, one-per-program-or-fewer territory: + +- ✅ A non-obvious algorithm invariant: \`comment:'invariant: divisor only divides the residual once per outer iteration'\`. +- ✅ Why a magic number: \`comment:'7 = max retries before circuit-break per provider SLA'\`. +- ✅ A subtle workaround: \`comment:'+1 because the API is 1-indexed despite the docs'\`. + +\`docs\` on a TYPE field is different — those become user-facing labels +for \`fns.ask\` and the LLM-downstream schema description. Set \`docs\` +on each prop of an output type you pass to \`fns.ask\` / \`fns.llm\`, +because the user/llm sees it. Do NOT also put \`comment\` on every +ExprDef that happens to use that type — \`docs\` lives on the type once +and is enough. + +Rule of thumb: if removing the comment loses NO information that the +reader can't recover from the structure, omit it. The default for any +node you author is \`comment: undefined\` — opt INTO comments rarely, +not opt OUT for trivia. + +Also: do NOT populate \`prefix\` / \`suffix\` / \`minPrecision\` / +\`maxPrecision\` on \`num\` unless they actually change formatting. +Padding with defaults like \`prefix: ""\` adds visual noise. + ## Common gotchas - **\`loop.over\` modes — iterable vs. bool while-loop.** When @@ -534,7 +614,7 @@ When this happens: Read the method's def in the type catalog above; \`mod(other: num): num\` means the call args obj has key \`other\`. - **Don't redeclare a function inline after asking - \`find_or_create_functions\` for it.** Either the engineer succeeded + \`find_or_create_functions\` for it.** Either the designer succeeded (use the saved fn directly via \`{name}({...args})\`) or it failed (escalate per the section above). @@ -576,7 +656,10 @@ Respond to the most recent user message in light of the prior turns.`, finish, research, ask, + searchFns, + searchVars, + printFn, ], dynamic: true, - toolIterations: 20, + toolIterations: toolIterationsConfig(), }); diff --git a/packages/ginny/src/prompts/researcher.ts b/packages/ginny/src/prompts/researcher.ts index e49b358..7a6762c 100644 --- a/packages/ginny/src/prompts/researcher.ts +++ b/packages/ginny/src/prompts/researcher.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { ai } from '../ai'; -import { modelFor } from '../model-selection'; +import { modelFor, toolIterationsConfig } from '../model-selection'; import { webSearch } from '../tools/web-search'; import { webGetPage } from '../tools/web-get-page'; import { ask } from '../tools/ask'; @@ -36,7 +36,7 @@ Keep answers concise and factual. Cite source URLs. Question: {{question}}`, input: (input: { question: string }) => ({ question: input.question }), tools: [webSearch, webGetPage, ask], - toolIterations: 10, + toolIterations: toolIterationsConfig(), excludeMessages: true, schema: z.object({ answer: z.string().describe('The researched answer, concise and factual.'), diff --git a/packages/ginny/src/tools/find-or-create-fns.ts b/packages/ginny/src/tools/find-or-create-fns.ts index d1d4450..a20206b 100644 --- a/packages/ginny/src/tools/find-or-create-fns.ts +++ b/packages/ginny/src/tools/find-or-create-fns.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; import { ai } from '../ai'; -import { engineer } from '../prompts/engineer'; +import { designer } from '../prompts/designer'; import { runSubagent } from '../progress'; import { MAX_PROGRAMMER_DEPTH } from '../context'; -interface EngineerResult { +interface DesignerResult { use: string[]; created: string[]; } @@ -12,23 +12,28 @@ interface EngineerResult { export const findOrCreateFunctions = ai.tool({ name: 'find_or_create_functions', description: 'Locate or author reusable functions. Returns their signatures.', - instructions: 'Delegates to the engineer. Provide a description of what is needed.', + instructions: 'Delegates to the designer. Provide a description of what is needed.', schema: z.object({ description: z.string().describe('What functions are needed and why'), }), - // The engineer's `create_new_fn` recursively spawns another programmer. - // Past the depth cap, exposing this tool lets the agent loop forever - // (programmer → engineer → programmer → engineer → ...). Withholding - // it forces the deepest programmer to author the function inline via - // write/test/finish, which is what the user actually wants. - applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, + // Two reasons to withhold this tool: + // 1. Past the depth cap, exposing it lets the agent loop forever + // (programmer → designer → programmer → designer → ...). + // 2. When `targetFn` is set, the programmer's job IS to author that + // specific fn's body inline — delegating would just respawn a + // designer for the SAME description and recurse uncontrolled, + // exhausting heap before the depth cap kicks in (each level + // allocates fresh tool schemas, conversation messages, etc.). + // In either case, force write/test/finish on the body directly. + applicable: (ctx) => + !ctx.targetFn && (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, call: async (input: { description: string }, _refs, ctx) => { const result = await runSubagent( - `engineer: ${input.description}`, - () => engineer.get('stream', { description: input.description }, ctx), + `designer: ${input.description}`, + () => designer.get('stream', { description: input.description }, ctx), ctx.signal, ); - if (!result) return 'Engineer returned no result.'; + if (!result) return 'Designer returned no result.'; const { use = [], created = [] } = result; const loaded: string[] = []; @@ -46,7 +51,7 @@ export const findOrCreateFunctions = ai.tool({ ctx.engine.registerGlobal(name, { type: fnType, value: null }); ctx.loadedFns.add(name); } catch { - // The engineer claimed this function exists but there's no + // The designer claimed this function exists but there's no // file on disk (or it failed to parse). Drop the ghost and // surface it so the caller knows not to trust the claim. ghosts.push(name); @@ -63,7 +68,7 @@ export const findOrCreateFunctions = ai.tool({ if (loaded.length === 0 && ghosts.length === 0) { return [ - '// FAILED: the engineer could not create or find any function for that description.', + '// FAILED: the designer could not create or find any function for that description.', '// Likely causes: the inner programmer never reached a passing test for the signature,', '// or no existing saved fn matched the keywords.', '//', @@ -77,7 +82,7 @@ export const findOrCreateFunctions = ai.tool({ if (loaded.length > 0) parts.push(loaded.join('\n')); if (ghosts.length > 0) { parts.push( - `// Engineer claimed these were created but no file was written: ${ghosts.join(', ')}.\n` + + `// Designer claimed these were created but no file was written: ${ghosts.join(', ')}.\n` + `// Treat them as NOT available — DO NOT inline-define them. Either retry find_or_create_functions\n` + `// with a clearer description, or report the failure to the user.`, ); diff --git a/packages/ginny/src/tools/finish.ts b/packages/ginny/src/tools/finish.ts index 17532fe..bfb3c71 100644 --- a/packages/ginny/src/tools/finish.ts +++ b/packages/ginny/src/tools/finish.ts @@ -54,9 +54,9 @@ export const finish = ai.tool({ const name = input.saveAs; const r = ctx.registry; - // When the engineer set up this run via `create_new_fn`, the + // When the designer set up this run via `create_new_fn`, the // intended signature lives on `ctx.targetFn`. Use it so the saved - // type matches what the engineer designed instead of being + // type matches what the designer designed instead of being // inferred from the body — `engine.typeOf(draft)` of an if/elif // chain lands on weird unions like `or`, useless to // callers expecting `(n: num) => list`. @@ -68,11 +68,11 @@ export const finish = ai.tool({ // path walker invokes this directly — no ginny-side callable // wrapping needed. // - // When the engineer declared `call.types` aliases, the parsed + // When the designer declared `call.types` aliases, the parsed // argsType / returnsType have those aliases ALREADY INLINED. // Emitting the inlined toJSON would defeat the verbosity- // reduction point. Use `targetFn.sourceArgs` / `sourceReturns` - // (the engineer's original input) so the saved fn keeps the + // (the designer's original input) so the saved fn keeps the // alias references intact. const useAliases = useTarget && ctx.targetFn?.callTypes && ctx.targetFn?.sourceArgs && ctx.targetFn?.sourceReturns; const fnTypeDef: TypeDef = { diff --git a/packages/ginny/src/tools/print-fn.ts b/packages/ginny/src/tools/print-fn.ts new file mode 100644 index 0000000..7ff056b --- /dev/null +++ b/packages/ginny/src/tools/print-fn.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import type { Type, TypeDef } from '@aeye/gin'; +import { formatParams, renderGenerics } from '@aeye/gin'; +import { ai } from '../ai'; + +/** + * Inspect a saved function's source. The programmer often wants to + * read the body of a fn it (or a previous session) created — to reuse + * a pattern, debug a failure, or compose a new program on top. + * + * Renders a TypeScript-like declaration: + * fn (): { + * type = ; // call.types declared aliases + * ... + * // call.get rendered via engine.toCode + * } + * + * Aliases declared on `call.types` appear as `type` lines at the top + * of the body block — same as how a TypeScript fn would declare local + * type aliases before the implementation. + * + * `includeComments` defaults to true. Set false to suppress docs and + * inline / line comments at every level — `noComments` threads through + * gin's toCode so wrap decisions reflect the actual rendered content. + */ +export const printFn = ai.tool({ + name: 'print_fn', + description: 'Render a saved function as a TypeScript-like fn declaration.', + instructions: + 'Look up a saved fn by name and return its signature + body in `fn name(args): R { types; body }` form. ' + + 'Use `includeComments: false` to drop docs/comment annotations and just show the structure. ' + + 'Errors when the fn does not exist on disk.', + schema: z.object({ + name: z.string().describe('Function name (matches the file at `./fns/.json`).'), + includeComments: z.boolean() + .optional() + .default(true) + .describe('Include inline `/* docs */` and `// line` comments. Default true.'), + }), + call: async (input: { name: string; includeComments?: boolean }, _refs, ctx) => { + let typeDef: TypeDef; + try { + typeDef = ctx.store.readFn(input.name); + } catch { + return `// FAILED: function '${input.name}' not found at \`./fns/${input.name}.json\`.`; + } + + const fnType = ctx.registry.parse(typeDef); + const includeComments = input.includeComments ?? true; + const codeOpts = { includeComments }; + + const call = fnType.call(); + if (!call) return `// FAILED: '${input.name}' is not a callable type — got ${fnType.name}.`; + + const generics = renderGenerics(fnType.generic, codeOpts); + const params = formatParams(call.args, codeOpts); + const returns = call.returns ? call.returns.toCode(undefined, codeOpts) : 'void'; + + // Body lines: `type = ...;` declarations for each declared + // call.types alias, then the rendered body. + const bodyLines: string[] = []; + if (call.types) { + for (const [aliasName, aliasType] of Object.entries(call.types as Record)) { + bodyLines.push(` type ${aliasName} = ${aliasType.toCode(undefined, codeOpts)};`); + } + if (Object.keys(call.types).length > 0) bodyLines.push(''); + } + if (call.get) { + try { + const body = ctx.engine.toCode(call.get, { expectsValue: false, includeComments }); + // Indent each line by two spaces so it sits visually inside + // the fn block. Empty lines stay empty. + const indented = body.split('\n').map((l) => l.length > 0 ? ` ${l}` : l).join('\n'); + bodyLines.push(indented); + } catch (e: unknown) { + bodyLines.push(` // body render failed: ${e instanceof Error ? e.message : String(e)}`); + } + } else { + bodyLines.push(' // (no body — fn declared with signature only)'); + } + + const docsLine = includeComments && (typeDef as { docs?: string }).docs + ? `// ${(typeDef as { docs?: string }).docs}\n` + : ''; + return `${docsLine}fn ${input.name}${generics}(${params}): ${returns} {\n${bodyLines.join('\n')}\n}`; + }, +}); diff --git a/packages/ginny/src/tools/search-fns.ts b/packages/ginny/src/tools/search-fns.ts new file mode 100644 index 0000000..4aa6eb5 --- /dev/null +++ b/packages/ginny/src/tools/search-fns.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { ai } from '../ai'; + +/** + * Enumerate / keyword-search the saved-function catalog on disk + * (`./fns/*.json`). With no keywords supplied, returns every fn up + * to `limit` — that's how the programmer answers "what fns are + * available?" without spawning the designer. + * + * The store's `searchFns` already does both: + * - empty keywords or fewer than `THRESHOLD` (default 20) saved fns + * → return everything up to `limit`. + * - otherwise → score by keyword match, return top `limit`. + * + * Combine with `print_fn(name)` to inspect a specific fn's body. + */ +export const searchFns = ai.tool({ + name: 'search_fns', + description: 'List or keyword-search saved functions in the catalog (./fns/*.json).', + instructions: + 'Pass an empty `keywords` array to enumerate every saved fn (up to `limit`). ' + + 'Pass keywords to score-rank when the catalog grows beyond ~20 entries. ' + + 'Returns one line per fn — `name: `. Use `print_fn(name)` for the full signature + body.', + schema: z.object({ + keywords: z.array(z.string()).default([]), + limit: z.number().optional().default(20), + }), + call: async (input: { keywords: string[]; limit?: number }, _refs, ctx) => { + const results = ctx.store.searchFns({ keywords: input.keywords, limit: input.limit }); + if (results.length === 0) { + return input.keywords.length === 0 + ? 'No saved functions yet. Call `find_or_create_functions` to author one.' + : `No functions matched [${input.keywords.join(', ')}].`; + } + return results.map((r) => `${r.name}: ${r.summary}`).join('\n'); + }, +}); diff --git a/packages/ginny/src/tools/search-vars.ts b/packages/ginny/src/tools/search-vars.ts new file mode 100644 index 0000000..1b8425f --- /dev/null +++ b/packages/ginny/src/tools/search-vars.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { ai } from '../ai'; + +/** + * Enumerate / keyword-search the saved-vars catalog on disk + * (`./vars/*.json`). Companion to `search_fns` for the data side — + * answers "what `vars.*` are available?" without spawning the dba. + * + * Pair with reading `vars.` directly in a program (the runtime + * makes every saved var available under the `vars` global). + */ +export const searchVars = ai.tool({ + name: 'search_vars', + description: 'List or keyword-search saved vars in the catalog (./vars/*.json).', + instructions: + 'Pass an empty `keywords` array to enumerate every saved var (up to `limit`). ' + + 'Pass keywords to score-rank when the catalog grows large. Returns one ' + + 'line per var — `name: `. Read a var\'s value via ' + + '`vars.` in a program.', + schema: z.object({ + keywords: z.array(z.string()).default([]), + limit: z.number().optional().default(20), + }), + call: async (input: { keywords: string[]; limit?: number }, _refs, ctx) => { + const results = ctx.store.searchVars({ keywords: input.keywords, limit: input.limit }); + if (results.length === 0) { + return input.keywords.length === 0 + ? 'No saved vars yet. Call `find_or_create_vars` to declare one.' + : `No vars matched [${input.keywords.join(', ')}].`; + } + return results.map((r) => `${r.name}: ${r.summary}`).join('\n'); + }, +}); diff --git a/packages/ginny/src/tools/test.ts b/packages/ginny/src/tools/test.ts index 8c706e4..c4d0478 100644 --- a/packages/ginny/src/tools/test.ts +++ b/packages/ginny/src/tools/test.ts @@ -8,7 +8,7 @@ import { withAskHandler } from '../natives/ask'; /** * Build the Zod sub-schema the model sees for `args`. * - * - When the engineer is authoring a fn (`ctx.targetFn?.argsType` is + * - When the designer is authoring a fn (`ctx.targetFn?.argsType` is * set), use that obj type's value-side schema directly. The model * sees `{ n: number, m: string }` instead of an opaque * `Record` and stops trying to invent wrapper @@ -101,7 +101,7 @@ export const test = ai.tool({ }); /** - * Engineer-driven flow: the draft is a function body. + * Designer-driven flow: the draft is a function body. * * Wrap it in a `LambdaExpr` and invoke through gin's standard call * machinery so the body sees `args` and `recurse` in scope and diff --git a/packages/ginny/src/tools/write.ts b/packages/ginny/src/tools/write.ts index b45d90b..fa2bc2c 100644 --- a/packages/ginny/src/tools/write.ts +++ b/packages/ginny/src/tools/write.ts @@ -31,7 +31,7 @@ export const write = ai.tool({ } // Build the type-scope `engine.validate` walks against. Globals - // are always there; when the engineer is authoring a fn, also + // are always there; when the designer is authoring a fn, also // bind `args` (the parameter obj) and `recurse` (the function // itself, for self-calls). Matches gin's runtime call binding — // see `gin/src/path.ts:286-287` for the saved-fn path and @@ -48,9 +48,11 @@ export const write = ai.tool({ } let problemsNote = ''; + let problemsCount = 0; try { const problems = ctx.engine.validate(input.program, scope); - if (problems.list.length > 0) { + problemsCount = problems.list.length; + if (problemsCount > 0) { const lines = problems.list.map((p) => { const path = p.path.length > 0 ? ` @ ${p.path.join('.')}` : ''; return ` - [${p.severity}] ${p.code}: ${p.message}${path}`; @@ -61,12 +63,19 @@ export const write = ai.tool({ // validate shouldn't throw, but be defensive — a thrown error // here shouldn't take down the write call. problemsNote = `\n\n[validation threw: ${e instanceof Error ? e.message : String(e)}]`; + problemsCount = 1; } - // Mirror to stderr for the user watching the terminal, and to - // ginny.log for the post-mortem. + // Stderr (the user's terminal) gets the rendered code plus a + // single-line problem count when there are issues. The full + // problem list and threading goes to ginny.log for post-mortem + // debugging — keeps the live view scannable while preserving + // every detail in the log. process.stderr.write(`\x1b[2m${code}\x1b[0m\n`); - if (problemsNote) process.stderr.write(`\x1b[31m${problemsNote.trim()}\x1b[0m\n`); + if (problemsCount > 0) { + const noun = problemsCount === 1 ? 'problem' : 'problems'; + process.stderr.write(`\x1b[31m[${problemsCount} validation ${noun} — see ginny.log for details]\x1b[0m\n`); + } logger.log(`write:\n${code}${problemsNote}`); return `Draft saved. Call test() to evaluate it.\n\n${code}${problemsNote}`; From b634505ae55979ce77ff5089d3295342c66fb902 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Thu, 30 Apr 2026 23:36:48 -0400 Subject: [PATCH 06/21] Generic constraints and obj compatibility Enforce generic constraints at call-sites, improve object compatibility rules, and add editor tooling and tests. Changes include: - Add tests: generic-constraints.test.ts and obj-compatible-widening.test.ts; adjust person.test expected rendering for empty args. - Validate generics on CallStep.callSiteScope: parse bindings in called scope, check against declared constraints (skip self-referential alias as "no constraint"), and throw on violations; empty-args render as () in Path.toCode. - FnType: treat declared generics as constraint types (not bound placeholders) when parsing; use any() for unconstrained params; adjust compatibility semantics (args treated bivariantly for pragmatic structural checks). - ObjType.compatible: allow this-type optional fields to be absent on other, support exact mode to reject extras, and refine semantics used by edit-compat tooling. - Prop/getter evaluation and Path getter: catch ReturnSignal so saved fn/method bodies using flow:'return' unwind to their call boundary; import ReturnSignal where needed. - Expr schemas: remove spurious comment fields on lightweight expressions (flow/get/new) so strict-mode rejects noise. - Analysis/Engine: add optional ValidateContext to validate entry points so callers can mark root as already inside a loop or lambda. - Ginny natives: update generic constraints for ask/fetch/llm to reflect intended constraints (any / text|obj / any) instead of implying defaults. - Add edit_fn tool in designer: allows safe editing of saved functions with backwards-compat checks (args contravariant, returns covariant), spawns inner programmer for body authoring, and persists on success. Add edit-type tool to programmer prompt and expand programmer guidance (plan-and-approve workflow) and generic-binding docs. Rationale: these changes make generic parameters enforceable and explicit (constraints vs defaults), fix object compatibility to support edit/replace workflows, improve control-flow handling for saved bodies, and provide tools for safe on-disk function edits. --- .../src/__tests__/generic-constraints.test.ts | 209 ++++++++++++++++ .../__tests__/obj-compatible-widening.test.ts | 110 +++++++++ packages/gin/src/__tests__/person.test.ts | 4 +- packages/gin/src/analysis.ts | 17 +- packages/gin/src/engine.ts | 13 +- packages/gin/src/exprs/flow.ts | 6 +- packages/gin/src/exprs/get.ts | 4 +- packages/gin/src/exprs/lambda.ts | 14 +- packages/gin/src/exprs/new.ts | 13 +- packages/gin/src/path.ts | 52 +++- packages/gin/src/type.ts | 14 +- packages/gin/src/types/fn.ts | 31 ++- packages/gin/src/types/obj.ts | 14 +- packages/ginny/src/natives/ask.ts | 8 +- packages/ginny/src/natives/fetch.ts | 6 +- packages/ginny/src/natives/llm.ts | 8 +- packages/ginny/src/prompts/designer.ts | 231 +++++++++++++++++- packages/ginny/src/prompts/programmer.ts | 130 +++++++++- packages/ginny/src/tools/edit-type.ts | 92 +++++++ packages/ginny/src/tools/write.ts | 20 +- 20 files changed, 940 insertions(+), 56 deletions(-) create mode 100644 packages/gin/src/__tests__/generic-constraints.test.ts create mode 100644 packages/gin/src/__tests__/obj-compatible-widening.test.ts create mode 100644 packages/ginny/src/tools/edit-type.ts diff --git a/packages/gin/src/__tests__/generic-constraints.test.ts b/packages/gin/src/__tests__/generic-constraints.test.ts new file mode 100644 index 0000000..eb3d0f9 --- /dev/null +++ b/packages/gin/src/__tests__/generic-constraints.test.ts @@ -0,0 +1,209 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine } from '../index'; +import { CallStep } from '../path'; +import { AliasType } from '../types/alias'; + +/** + * Constraints on generics — declared via `generic: { R: }`. + * The constraint is the type a call-site binding for R must satisfy + * (`constraint.compatible(binding) === true`); it is NOT a default + * resolution. R itself stays an unresolved AliasType placeholder until + * a call-site binding layers it into the scope. + * + * Semantics: + * - Bare `{name: 'R'}` inside the signature parses as AliasType('R') + * and resolves only through caller-supplied scope, never to its + * constraint. + * - `CallStep.callSiteScope(calledType)` validates each binding + * against the declared constraint and throws on violation. + * - `R: alias('R')` is the canonical "unconstrained" declaration — + * no satisfies check is run for that form. (`any` works too — + * compatible() is permissive — but the self-ref form is what the + * declaration-site reads as "no constraint".) + */ +describe('generic constraints', () => { + test('unconstrained generic — any binding accepted', () => { + const r = createRegistry(); + // identity({x: T}): T — declared with `any` constraint. + const identity = r.fn( + r.obj({ x: { type: r.alias('T') } }), + r.alias('T'), + undefined, + { T: r.any() }, + ); + + const stepNum = new CallStep({}, { T: { name: 'num' } }); + const stepText = new CallStep({}, { T: { name: 'text' } }); + + expect(() => stepNum.callSiteScope(identity)).not.toThrow(); + expect(() => stepText.callSiteScope(identity)).not.toThrow(); + }); + + test('union constraint — only members of the union are accepted', () => { + const r = createRegistry(); + // describe(...) — like fns.llm's R constraint. + const describer = r.fn( + r.obj({}), + r.alias('R'), + undefined, + { R: r.or([r.text(), r.obj({})]) }, + ); + + // Accepted: text fits the or constraint. + expect(() => + new CallStep({}, { R: { name: 'text' } }).callSiteScope(describer), + ).not.toThrow(); + // Accepted: obj fits. + expect(() => + new CallStep({}, { R: { name: 'obj', props: { x: { type: { name: 'num' } } } } }).callSiteScope(describer), + ).not.toThrow(); + // Rejected: num doesn't satisfy text | obj. + expect(() => + new CallStep({}, { R: { name: 'num' } }).callSiteScope(describer), + ).toThrow(/generic 'R' binding .* does not satisfy constraint/); + }); + + test('interface constraint — structural satisfaction at binding time', () => { + // Interface declaring a single method `length: num` (read as a prop + // returning num — text and list both expose it; num and bool do not). + // Used to demonstrate that the satisfies check is structural via + // `iface.compatible(binding)`. + const r = createRegistry(); + const Sized = r.iface({ + props: { length: { type: { name: 'num' } } }, + }); + + // measure({x: T}): num + const measure = r.fn( + r.obj({ x: { type: r.alias('T') } }), + r.num(), + undefined, + { T: Sized }, + ); + + // text has `length: num` → satisfies Sized. + expect(() => + new CallStep({}, { T: { name: 'text' } }).callSiteScope(measure), + ).not.toThrow(); + + // list has `length: num` → satisfies. + expect(() => + new CallStep({}, { T: { name: 'list', generic: { V: { name: 'num' } } } }).callSiteScope(measure), + ).not.toThrow(); + + // num has no `length` prop → does NOT satisfy. + expect(() => + new CallStep({}, { T: { name: 'num' } }).callSiteScope(measure), + ).toThrow(/generic 'T' binding 'num' does not satisfy constraint/); + + // bool has no `length` prop → does NOT satisfy. + expect(() => + new CallStep({}, { T: { name: 'bool' } }).callSiteScope(measure), + ).toThrow(/generic 'T' binding 'bool' does not satisfy constraint/); + }); + + test('self-referencing constraint (R: alias R) is unconstrained', () => { + // `{ R: alias('R') }` is a self-reference — the constraint resolves + // to itself, declaring "this generic has no real constraint". The + // satisfies check is skipped for this form so any binding is accepted. + const r = createRegistry(); + const identity = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.alias('R') }, + ); + + expect(() => + new CallStep({}, { R: { name: 'num' } }).callSiteScope(identity), + ).not.toThrow(); + expect(() => + new CallStep({}, { R: { name: 'text' } }).callSiteScope(identity), + ).not.toThrow(); + expect(() => + new CallStep({}, { R: { name: 'bool' } }).callSiteScope(identity), + ).not.toThrow(); + }); + + test('constraint is not a default — unbound R stays a placeholder', () => { + // The constraint type is stored on `fnType.generic[k]` but is NOT + // bound into the captured scope. Bare `alias('R')` inside the + // signature stays unresolved (AliasType placeholder); only call- + // site bindings provide concrete resolution. + const r = createRegistry(); + const fn = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.text() }, // constraint, not default + ); + + // Without a call-site binding, the captured fn scope does NOT + // resolve R to text. The args type's `x` field is AliasType('R') + // and stays so when accessed via the fn's own scope. + const argsType = fn.call()!.args; + const xField = (argsType as unknown as { fields: Record }).fields.x; + expect(xField.type).toBeInstanceOf(AliasType); + expect((xField.type as AliasType).options.name).toBe('R'); + + // The constraint IS retained in `fn.generic` for later validation. + expect(fn.generic.R!.name).toBe('text'); + }); + + test('runtime call: binding satisfying the constraint resolves the return type', async () => { + // End-to-end: build a generic identity-like fn with a `text|obj` + // constraint, invoke at the engine level with an explicit binding. + // The path's typeOf reflects the bound R; the binding succeeds. + const r = createRegistry(); + const e = new Engine(r); + + const identity = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.or([r.text(), r.obj({})]) }, + ); + + const expr = { + kind: 'get', + path: [ + { prop: 'f' }, + { + args: { x: { kind: 'new', type: { name: 'text' }, value: 'hi' } }, + generic: { R: { name: 'text' } }, + }, + ], + } as const; + + const scope = new Map([['f', identity]]); + expect(e.typeOf(expr, scope).name).toBe('text'); + }); + + test('runtime call: binding violating the constraint throws', () => { + const r = createRegistry(); + const e = new Engine(r); + + const identity = r.fn( + r.obj({ x: { type: r.alias('R') } }), + r.alias('R'), + undefined, + { R: r.or([r.text(), r.obj({})]) }, + ); + + // bool doesn't satisfy text|obj — typeOf walks callSiteScope which + // throws on the satisfies failure. + const expr = { + kind: 'get', + path: [ + { prop: 'f' }, + { + args: { x: { kind: 'new', type: { name: 'bool' }, value: true } }, + generic: { R: { name: 'bool' } }, + }, + ], + } as const; + + const scope = new Map([['f', identity]]); + expect(() => e.typeOf(expr, scope)).toThrow(/generic 'R' binding 'bool' does not satisfy/); + }); +}); diff --git a/packages/gin/src/__tests__/obj-compatible-widening.test.ts b/packages/gin/src/__tests__/obj-compatible-widening.test.ts new file mode 100644 index 0000000..9781032 --- /dev/null +++ b/packages/gin/src/__tests__/obj-compatible-widening.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry } from '../registry'; + +/** + * `ObjType.compatible(other)` — used by edit-compat tooling and other + * subset checks. Semantics: "every value of `other` is also a valid + * value of `this`". For obj types specifically: + * + * - Each field declared on `this` must appear on `other` with a + * type that satisfies `thisField.compatible(otherField)` — + * OR be optional, in which case `other` may simply omit it. + * - In `opts.exact`, the field sets must match exactly (no extras + * on `this` beyond what `other` declares). + * - `other` may have extra fields that `this` doesn't declare — + * those are ignored by `this`'s validator and don't affect + * compatibility. + * + * The "extra optional fields on `this`" rule is what makes the + * canonical edit-compat scenario work without a special API: + * `{x:num, y?:bool}.compatible({x:num})` is true because callers + * producing the simpler shape still produce values the wider shape + * accepts (the missing `y` defaults to undefined, which optional + * handles). + */ +describe('ObjType.compatible — widening / edit-compat scenarios', () => { + const r = createRegistry(); + + test('identical shapes compatible', () => { + const a = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + const b = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + expect(a.compatible(b)).toBe(true); + expect(b.compatible(a)).toBe(true); + }); + + test('this has extra OPTIONAL field — other may omit it', () => { + const wider = r.obj({ x: { type: r.num() }, flag: { type: r.optional(r.bool()) } }); + const narrow = r.obj({ x: { type: r.num() } }); + // Every narrow value (no flag) is valid for wider (flag undefined → optional accepts). + expect(wider.compatible(narrow)).toBe(true); + // Reverse: every wider value (with flag) is valid for narrow (extra ignored). + expect(narrow.compatible(wider)).toBe(true); + }); + + test('this has extra REQUIRED field — other must have it too', () => { + const wider = r.obj({ x: { type: r.num() }, flag: { type: r.bool() } }); + const narrow = r.obj({ x: { type: r.num() } }); + // narrow values lack `flag`; wider expects it required → not compatible. + expect(wider.compatible(narrow)).toBe(false); + // Other direction: every wider value still satisfies narrow (extras ignored). + expect(narrow.compatible(wider)).toBe(true); + }); + + test('canonical edit example — replacement direction', () => { + // old = {x:num, y:num}; new = {x:num, y:num|text, z?:bool} + // Every old value satisfies new ⇒ `new.compatible(old) === true`. + // Some new values DON'T satisfy old (y can be text) ⇒ `old.compatible(new) === false`. + const oldT = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + const newT = r.obj({ + x: { type: r.num() }, + y: { type: r.or([r.num(), r.text()]) }, + z: { type: r.optional(r.bool()) }, + }); + expect(newT.compatible(oldT)).toBe(true); + expect(oldT.compatible(newT)).toBe(false); + }); + + test('exact mode rejects extras on either side', () => { + const a = r.obj({ x: { type: r.num() }, y: { type: r.optional(r.bool()) } }); + const b = r.obj({ x: { type: r.num() } }); + // Without exact: a has optional y, b lacks it — accepted. + expect(a.compatible(b)).toBe(true); + // With exact: extras on `a` not in `b` are rejected. + expect(a.compatible(b, { exact: true })).toBe(false); + }); + + test('removing a required field is rejected for replacement', () => { + // old has y required; new omits y entirely. + const oldT = r.obj({ x: { type: r.num() }, y: { type: r.num() } }); + const newT = r.obj({ x: { type: r.num() } }); + // new.compatible(old)? new iterates new's only field (x), finds it on + // old with compatible type. Returns true — but this answers the wrong + // question for edit-compat (it just confirms new is a subset). + expect(newT.compatible(oldT)).toBe(true); + // old.compatible(new)? old iterates {x, y}. y not on new, y is REQUIRED + // on old → false. This is the rejection we want. + expect(oldT.compatible(newT)).toBe(false); + // The correct edit-compat question is "does the new contract preserve + // the old's guarantees?" which boils down to checking BOTH directions + // when shapes change: old.compatible(new) must be true (no fields lost) + // AND new.compatible(old) must be true (no required fields added). + // Edit tooling should call both; here y-loss flips one side false. + }); + + test('field type widening accepted in replacement direction', () => { + const oldT = r.obj({ y: { type: r.num() } }); + const newT = r.obj({ y: { type: r.or([r.num(), r.text()]) } }); + // new.compatible(old): or.compatible(num) is true (or accepts num). + expect(newT.compatible(oldT)).toBe(true); + // Reverse fails: num.compatible(or) is false. + expect(oldT.compatible(newT)).toBe(false); + }); + + test('field type narrowing rejected in replacement direction', () => { + const oldT = r.obj({ y: { type: r.or([r.num(), r.text()]) } }); + const newT = r.obj({ y: { type: r.num() } }); + // new.compatible(old): num.compatible(or) is false (num doesn't + // accept text). Narrowing breaks callers who supply text. + expect(newT.compatible(oldT)).toBe(false); + }); +}); diff --git a/packages/gin/src/__tests__/person.test.ts b/packages/gin/src/__tests__/person.test.ts index 9f49d93..bf51436 100644 --- a/packages/gin/src/__tests__/person.test.ts +++ b/packages/gin/src/__tests__/person.test.ts @@ -130,7 +130,9 @@ describe('Person.fullName integration', () => { } as const; const code = e.toCode(program); - expect(code).toBe('p.fullName({})'); + // Empty args render as bare `()` — the implicit `{}` is dropped + // for readability. See path.ts CallStep.toCode. + expect(code).toBe('p.fullName()'); // typeOf on the method call should resolve to text via the Fn's returns. // (Requires a scope with p: Person; validate helps here.) diff --git a/packages/gin/src/analysis.ts b/packages/gin/src/analysis.ts index 4012887..4728194 100644 --- a/packages/gin/src/analysis.ts +++ b/packages/gin/src/analysis.ts @@ -26,10 +26,21 @@ function parseExprSafe(engine: Engine, expr: ExprDef): Expr | undefined { catch { return undefined; } } -/** Top-level: walk an expression tree collecting Problems. Never throws. */ -export function validate(engine: Engine, expr: ExprDef | Expr, scope: Locals): Problems { +/** Top-level: walk an expression tree collecting Problems. Never throws. + * + * `ctx` lets callers mark the entry expression as already inside a + * loop or lambda — useful when validating a saved fn's body (where + * `return` is legal even though the body isn't wrapped in a + * LambdaExpr) or a snippet meant to run inside a loop. Defaults to + * the top-level program shape (neither loop nor lambda). */ +export function validate( + engine: Engine, + expr: ExprDef | Expr, + scope: Locals, + ctx: ValidateContext = { inLoop: false, inLambda: false }, +): Problems { const p = new Problems(); - walkValidate(engine, expr, scope, p, { inLoop: false, inLambda: false }); + walkValidate(engine, expr, scope, p, ctx); return p; } diff --git a/packages/gin/src/engine.ts b/packages/gin/src/engine.ts index 06cbbc8..71b751d 100644 --- a/packages/gin/src/engine.ts +++ b/packages/gin/src/engine.ts @@ -95,10 +95,19 @@ export class Engine { /** * Walk an expression tree and collect Problems (unknown vars, unknown * props / natives, out-of-place break/return, etc.). Never throws. + * + * `ctx` lets the caller mark the root as already inside a lambda or + * loop — needed when validating a saved fn's body (the body has + * `args`/`recurse` bound and `return` is legal there even though + * there's no enclosing LambdaExpr). Defaults to top-level shape. */ - validate(expr: ExprDef | Expr, scope?: Locals): Problems { + validate( + expr: ExprDef | Expr, + scope?: Locals, + ctx?: import('./expr').ValidateContext, + ): Problems { const s = scope ?? this.globalTypeScope(); - return validateAnalysis(this, expr, s); + return validateAnalysis(this, expr, s, ctx); } /** diff --git a/packages/gin/src/exprs/flow.ts b/packages/gin/src/exprs/flow.ts index 396671b..ec4d601 100644 --- a/packages/gin/src/exprs/flow.ts +++ b/packages/gin/src/exprs/flow.ts @@ -11,7 +11,6 @@ import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; -import { baseExprFields } from '../schemas'; import type { TypeScope } from '../type-scope'; export type FlowAction = 'break' | 'return' | 'continue' | 'exit' | 'throw'; @@ -45,7 +44,10 @@ export class FlowExpr extends Expr { static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('flow'), - ...baseExprFields, + // No `comment` field — keywords (return/break/continue/throw/exit) + // already say what they do; comments are pure noise. Strict-mode + // schema rejects them. Comments belong on statement-shaped Exprs + // (if/switch/define/block/lambda) only. action: z.enum(['break', 'continue', 'return', 'exit', 'throw']).describe( 'Which control-flow signal to raise. ' + '`break`/`continue` only valid inside a loop. ' + diff --git a/packages/gin/src/exprs/get.ts b/packages/gin/src/exprs/get.ts index 64d1cd5..4917021 100644 --- a/packages/gin/src/exprs/get.ts +++ b/packages/gin/src/exprs/get.ts @@ -10,7 +10,7 @@ import type { Problems } from '../problem'; import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; -import { baseExprFields, pathStepSchema } from '../schemas'; +import { pathStepSchema } from '../schemas'; import type { TypeScope } from '../type-scope'; /** @@ -32,7 +32,7 @@ export class GetExpr extends Expr { static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('get'), - ...baseExprFields, + // No `comment` field — see header comment above. path: z .array(pathStepSchema(opts)) .describe( diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index 4706284..dffbbb9 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -163,12 +163,18 @@ export class LambdaExpr extends Expr { } } -/** Build a body scope that exposes the fnType's generics and - * `call.types` aliases by name, so bare `{name: 'X'}` references - * inside the body / constraint resolve via AliasType. */ +/** Build a body scope that exposes the fnType's `call.types` aliases + * by name, so bare `{name: 'X'}` references inside the body / + * constraint resolve via AliasType. + * + * Generics are NOT bound here — their declared types are constraints, + * not active resolutions. Bare `{name: 'R'}` inside the body remains + * an unresolved AliasType placeholder; concrete resolution comes + * from call-site bindings layered into the scope at invocation + * time. (Aliases ARE bound, since `call.types` declarations are + * type-aliases — substitution targets, not parameters.) */ function buildBodyScope(parent: TypeScope, fnType: Type): TypeScope { const local = new LocalScope(parent); - for (const [name, t] of Object.entries(fnType.generic)) local.bind(name, t); const call = fnType.call(); if (call?.types) { for (const [name, t] of Object.entries(call.types)) local.bind(name, t); diff --git a/packages/gin/src/exprs/new.ts b/packages/gin/src/exprs/new.ts index 5344cb5..20bf41b 100644 --- a/packages/gin/src/exprs/new.ts +++ b/packages/gin/src/exprs/new.ts @@ -10,7 +10,6 @@ import type { Problems } from '../problem'; import { Expr, type ValidateContext } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { z } from 'zod'; -import { baseExprFields } from '../schemas'; import type { TypeScope } from '../type-scope'; /** @@ -43,6 +42,11 @@ export class NewExpr extends Expr { } static toSchema(opts: SchemaOptions): z.ZodTypeAny { + // No `comment` field on any branch below — `new` is a literal / + // constructor; the type + value already convey what it is, so an + // attached comment is pure noise. Strict-mode schema rejects it + // outright. Comments belong on statement-shaped Exprs only. + // // Strict mode: emit a discriminated union over every Type the LLM // could legitimately `new`: // - One branch per built-in Type class: `type` is that class's full @@ -65,7 +69,6 @@ export class NewExpr extends Expr { const instanceBranches = Array.from(byName.values()).map((t) => z.object({ kind: z.literal('new'), - ...baseExprFields, type: z.object({ name: z.literal(t.name) }).passthrough().describe( `Reference to the registered named type \`${t.name}\` — name-only, the registry resolves it to its full definition.`, ), @@ -83,7 +86,6 @@ export class NewExpr extends Expr { const classBranches = opts.registry.typeClasses().map((cls) => z.object({ kind: z.literal('new'), - ...baseExprFields, type: cls.toSchema(opts).describe( `Full TypeDef for a \`${cls.NAME}\` instance (name + options + per-class fields).`, ), @@ -100,7 +102,10 @@ export class NewExpr extends Expr { // Default (non-strict): any TypeDef + any value. return z.object({ kind: z.literal('new'), - ...baseExprFields, +// no `comment` field — comment-spam on `new`/`get`/`flow` Exprs is + // pure noise (the literal/path/keyword already conveys intent), so + // strict-mode schema rejects them outright. Comments belong on + // statement-shaped Exprs (if/switch/define/block/lambda) only. type: opts.Type.describe( 'TypeDef of the value being constructed. The `value` field is interpreted relative to this type — primitives take their raw form (`new num` → number), composites take Expr slots (`new list` → Expr[]).', ), diff --git a/packages/gin/src/path.ts b/packages/gin/src/path.ts index fc2d11a..4b37492 100644 --- a/packages/gin/src/path.ts +++ b/packages/gin/src/path.ts @@ -5,7 +5,7 @@ import type { TypeDef, } from './schema'; import { Value, val } from './value'; -import { ThrowSignal } from './flow-control'; +import { ReturnSignal, ThrowSignal } from './flow-control'; import type { GetSet, Type } from './type'; import { Expr } from './expr'; import type { Registry } from './registry'; @@ -90,16 +90,42 @@ export class CallStep extends PathStep { * when there are no bindings. Threaded through type-resolution * methods (`call`, `parse`, etc.) at the call site so AliasTypes * inside the called signature resolve to the bound types without - * rebuilding the type tree. */ + * rebuilding the type tree. + * + * Validates each binding against its declared constraint + * (`calledType.generic[name]`). A binding `T` is accepted iff + * `constraint.compatible(T)` — equivalently, `T` is assignable to + * the constraint. Throws on violation: parsing the binding into + * the call scope before that check would silently use an unsound + * type, so failing fast is the right call. + * + * Bindings for generic names the called type didn't declare are + * parsed into the call scope but not validated (they may target + * aliases declared on `call.types` or simply be ignored). */ callSiteScope(calledType: Type): TypeScope { if (!this.generic || Object.keys(this.generic).length === 0) { return calledType.scope; } const bindings: Record = {}; + const declaredGenerics = calledType.generic ?? {}; // Parse each binding TypeDef in the called type's own scope so // intra-binding name lookups (e.g. R: list) resolve naturally. for (const [k, def] of Object.entries(this.generic)) { - bindings[k] = calledType.scope.parse(def); + const bound = calledType.scope.parse(def); + const constraint = declaredGenerics[k]; + if (constraint) { + // Self-referential placeholder (e.g. `R: alias('R')`) means + // "no constraint" — skip the satisfies check, every binding + // is accepted. + const isSelfRef = constraint.name === 'alias' + && (constraint.options as { name?: string } | undefined)?.name === k; + if (!isSelfRef && !constraint.compatible(bound)) { + throw new Error( + `path: generic '${k}' binding '${bound.toCode()}' does not satisfy constraint '${constraint.toCode()}'`, + ); + } + } + bindings[k] = bound; } return new LocalScope(calledType.scope, bindings); } @@ -164,7 +190,9 @@ export class Path { } else if (step instanceof CallStep) { const entries = Object.entries(step.args); if (entries.length === 0) { - out += '({})'; + // No args — render as a bare `()`. Showing `({})` would be + // accurate but visually noisier (the empty obj is implied). + out += '()'; } else { const parts = entries.map(([k, v]) => `${k}: ${v.toCode(registry, { ...options, expectsValue: true })}`); const joined = joinAuto(parts); @@ -305,9 +333,19 @@ export class Path { } else if (callSpec?.get) { const getterCallable = async (newArgs: Value): Promise => { const recurseValue = new Value(callType, getterCallable); - return engine.evaluate(callSpec.get!, scope.child({ - args: newArgs, recurse: recurseValue, - })); + // Catch ReturnSignal so a saved fn body using `flow:'return'` + // unwinds to its own call boundary (not all the way out + // through the caller's enclosing lambda). + try { + return await engine.evaluate(callSpec.get!, scope.child({ + args: newArgs, recurse: recurseValue, + })); + } catch (sig) { + if (sig instanceof ReturnSignal) { + return sig.value ?? val(engine.registry.void(), undefined); + } + throw sig; + } }; current = await getterCallable(argsValue); } else { diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index d5f8108..a839350 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -6,6 +6,7 @@ import { Value, val } from './value'; import type { Node, CodeOptions } from './node'; import type { Engine } from './engine'; import { Problems } from './problem'; +import { ReturnSignal } from './flow-control'; import type { Scope } from './scope'; import type { JSONOf, RuntimeOf } from './json-type'; import { z } from 'zod'; @@ -135,7 +136,18 @@ export class Prop { const bindings: Record = { this: self, args: newArgs, recurse: recurseValue }; const sup = self.type.propSuperFor(self, name, 'get', scope, engine); if (sup) bindings.super = sup; - return engine.evaluate(getExpr, scope.child(bindings)); + // Catch `ReturnSignal` here so a saved fn body or method body + // can use `flow: 'return'` for early-exit. The body is the call + // boundary even though it's not literally wrapped in a + // LambdaExpr — same semantics as Lambda.evaluate's catch. + try { + return await engine.evaluate(getExpr, scope.child(bindings)); + } catch (sig) { + if (sig instanceof ReturnSignal) { + return sig.value ?? new Value(engine.registry.void(), undefined); + } + throw sig; + } }; return callable(argsValue); } diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 3b369a0..7c49d80 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -27,17 +27,23 @@ export class FnType extends Type> { static from(json: TypeDef, scope: TypeScope): FnType { const registry = scope.registry; - // Generics declared on the fn — bind each into a LocalScope so that - // bare `{name: 'T'}` inside the call signature resolves to the - // generic placeholder via AliasType (and supports later - // substitution via .bind). + // Generics declared on the fn — each entry's value is a CONSTRAINT + // type that bindings supplied at call sites must satisfy. The + // generic NAME itself stays unresolved in the captured scope: bare + // `{name: 'R'}` inside the call signature parses as an AliasType + // placeholder, NOT bound to its constraint. Concrete resolution + // only happens through call-site bindings (a CallStep's `generic` + // map layered into a LocalScope at invocation). + // + // Use `registry.any()` as the constraint when the parameter is + // unconstrained. A self-reference (`R: alias('R')`) also works as + // an unconstrained declaration — the alias resolves to itself in + // any context that doesn't supply a binding. const generic: Record = {}; const local = new LocalScope(scope); if (json.generic) { for (const [k, def] of Object.entries(json.generic)) { - const t = local.parse(def); - generic[k] = t; - local.bind(k, t); + generic[k] = local.parse(def); } } if (!json.call) { @@ -119,9 +125,16 @@ export class FnType extends Type> { compatible(other: Type, opts?: CompatOptions, scope?: TypeScope): boolean { if (!(other instanceof FnType)) return false; - // args: contravariant — this.args must accept other.args + // Bivariant on args: a satisfier with narrower args (e.g. `num.eq` + // takes `other: num`) is accepted as a witness of a wider-args + // interface (e.g. `iface.eq` takes `other: any`). This is the + // pragmatic structural-interface check most gin code wants — and + // matches TypeScript's default bivariant method-parameter rule. + // Strict-subtype variance (contravariant args / covariant returns) + // is what consumers like edit-compat want; those should split + // args + returns and use `compatible` directionally per side + // rather than calling `FnType.compatible` whole. if (!this._call.args.compatible(other._call.args, opts, scope)) return false; - // returns: covariant — other.returns must be compatible with this.returns if (this._call.returns && other._call.returns) { if (!this._call.returns.compatible(other._call.returns, opts, scope)) return false; } diff --git a/packages/gin/src/types/obj.ts b/packages/gin/src/types/obj.ts index f2c4166..7df73e2 100644 --- a/packages/gin/src/types/obj.ts +++ b/packages/gin/src/types/obj.ts @@ -122,10 +122,20 @@ export class ObjType> extends Type`; with one, the impl coerces R to whatever the + // caller's Type was. + { R: registry.any() }, ); } diff --git a/packages/ginny/src/natives/fetch.ts b/packages/ginny/src/natives/fetch.ts index cb7cea8..80c0390 100644 --- a/packages/ginny/src/natives/fetch.ts +++ b/packages/ginny/src/natives/fetch.ts @@ -65,6 +65,10 @@ export function registerFetchType(registry: Registry) { }), registry.alias('R'), undefined, - { R: registry.text() }, + // Constraint on R, not a default. fetch is fully untyped from gin's + // perspective — the response body is whatever the remote server + // hands back, and `output:` parses it into any gin Type the caller + // asks for. `any` reflects that. + { R: registry.any() }, ); } diff --git a/packages/ginny/src/natives/llm.ts b/packages/ginny/src/natives/llm.ts index 100613b..cd3e60f 100644 --- a/packages/ginny/src/natives/llm.ts +++ b/packages/ginny/src/natives/llm.ts @@ -46,6 +46,12 @@ export function registerLlmType(registry: Registry) { }), registry.alias('R'), undefined, - { R: registry.text() }, + // Constraint on R, not a default. The LLM call always returns either + // a primitive text reply or a structured `obj` matching whatever + // schema the caller passes via `output`. Anything outside that — + // bool, num, list, etc. — wouldn't round-trip through the model's + // structured-output channel reliably, so reject those bindings at + // call sites. + { R: registry.or([registry.text(), registry.obj({})]) }, ); } diff --git a/packages/ginny/src/prompts/designer.ts b/packages/ginny/src/prompts/designer.ts index cda6344..fdca456 100644 --- a/packages/ginny/src/prompts/designer.ts +++ b/packages/ginny/src/prompts/designer.ts @@ -277,6 +277,213 @@ const createNewFn = ai.tool({ }, }); +/** + * Edit an existing saved function. The new signature is checked + * against the old for backwards-compatibility BEFORE the programmer + * is spawned to write a fresh body: + * + * - args (contravariant): the new args obj must accept every + * old-args input. Concretely — every required field on the old + * args must be present on the new with a wider-or-equal type; + * newly-added fields must be optional. + * - returns (covariant): the new return type must produce values + * that fit the old return type's contract. Narrowing return is + * always fine; widening is rejected. + * + * The body is written from scratch — `targetFn` is set on the inner + * programmer's ctx exactly the same as `create_new_fn`. The disk + * record is overwritten on `finish()` only after the programmer + * reaches a passing test. + */ +const editFn = ai.tool({ + name: 'edit_fn', + description: 'Edit a saved function: new signature (compat-checked) + fresh body authored by an inner programmer.', + instructions: + 'Replace a saved function. The new args / returns are checked against the old: existing callers must keep working. ' + + 'Allowed: widen args (add optional params, widen field types), narrow returns. ' + + 'Rejected: removing required args, narrowing arg types, widening returns.', + schema: (ctx) => { + const opts = buildSchemas(ctx.registry); + return z.object({ + name: z.string().describe('Saved function name (matches the file at `./fns/.json`).'), + description: z.string().describe('Updated description for the body programmer'), + types: z + .record(z.string(), opts.Type as z.ZodType) + .optional() + .describe('Optional `call.types` aliases — same semantics as `create_new_fn`.'), + args: (opts.Type as z.ZodType).describe('New args TypeDef. Must accept every value the old args accepted.'), + returns: (opts.Type as z.ZodType).describe('New return TypeDef. Must be assignable back into the old return type.'), + }); + }, + applicable: (ctx) => (ctx.programmerDepth ?? 0) < MAX_PROGRAMMER_DEPTH - 1, + call: async ( + input: { + name: string; + description: string; + args: TypeDef; + returns: TypeDef; + types?: Record; + }, + _refs, + ctx, + ) => { + // 1. Read the existing fn off disk and parse it. If anything goes + // wrong here, the edit is fundamentally not possible — surface + // the reason and bail. + let oldFnDef: TypeDef; + try { + oldFnDef = ctx.store.readFn(input.name); + } catch { + return `// FAILED: function '${input.name}' not found at \`./fns/${input.name}.json\`. Use \`create_new_fn\` instead, or \`search_fns\` to find what's actually saved.`; + } + let oldArgsType: ObjType; + let oldReturnsType: Type; + try { + const oldFn = ctx.registry.parse(oldFnDef); + const oldCall = (oldFn as { _call?: { args: Type; returns?: Type } })._call; + if (!oldCall?.args || !(oldCall.args instanceof ObjType)) { + throw new Error('saved fn has no obj-typed args'); + } + if (!oldCall.returns) throw new Error('saved fn has no return type'); + oldArgsType = oldCall.args; + oldReturnsType = oldCall.returns; + } catch (e: unknown) { + return `// FAILED: could not parse on-disk fn '${input.name}': ${e instanceof Error ? e.message : String(e)}.`; + } + + // 2. Parse the proposed new signature exactly like create_new_fn. + let newArgsType: ObjType; + let newReturnsType: Type; + try { + const fnDef: TypeDef = { + name: 'function', + call: { + ...(input.types ? { types: input.types } : {}), + args: input.args, + returns: input.returns, + }, + }; + const parsedFn = ctx.registry.parse(fnDef); + const parsedCall = (parsedFn as { _call?: { args: Type; returns?: Type } })._call; + if (!parsedCall) throw new Error('parsed FnType has no call spec'); + if (!(parsedCall.args instanceof ObjType)) { + throw new Error(`expected args to be an obj type, got '${parsedCall.args.name}'`); + } + newArgsType = parsedCall.args; + if (!parsedCall.returns) throw new Error('returns is required'); + newReturnsType = parsedCall.returns; + } catch (e: unknown) { + return `// FAILED: could not parse new signature for '${input.name}': ${e instanceof Error ? e.message : String(e)}.`; + } + + // 3. Backwards-compat check. Args contravariant, returns + // covariant. We delegate to the per-Type `compatible` methods + // (obj's already accepts extra optional fields after the + // obj-compat fix; non-obj types fall through to the standard + // "values fit" relation). + if (!newArgsType.compatible(oldArgsType)) { + return ( + `// FAILED: new args type '${safeToCode(newArgsType)}' is not a backwards-compatible widening of old args '${safeToCode(oldArgsType)}'.\n` + + `// Allowed: add optional params, widen existing param types.\n` + + `// Rejected: removing required params, narrowing param types.` + ); + } + if (!oldReturnsType.compatible(newReturnsType)) { + return ( + `// FAILED: new return type '${safeToCode(newReturnsType)}' is not assignable to old '${safeToCode(oldReturnsType)}'.\n` + + `// Returns may NARROW (subset), not WIDEN — callers expecting the old shape must still receive values that fit it.` + ); + } + + // 4. Compat passed — spawn an inner programmer to author a fresh + // body, identical machinery to create_new_fn from this point. + const argsCode = (() => { try { return newArgsType.toCode(); } catch { return JSON.stringify(input.args); } })(); + const returnsCode = (() => { try { return newReturnsType.toCode(); } catch { return JSON.stringify(input.returns); } })(); + const paramNames: string[] = Object.keys(newArgsType.fields); + const paramList = paramNames.length === 0 + ? '(no parameters)' + : paramNames.map((p) => `\`${p}\``).join(', '); + + const parentChain = ctx.programmerChain ?? []; + const youAreHere: ProgrammerChainEntry = { + name: input.name, + argsCode, + returnsCode, + description: input.description, + }; + const innerChain: ProgrammerChainEntry[] = [...parentChain, youAreHere]; + + const request = [ + `You ARE the writer of this gin function. You're EDITING an existing saved fn — old signature is being replaced and a new body is needed. Do NOT call find_or_create_functions or delegate elsewhere.`, + ``, + `Function name: ${input.name}`, + `Old args: ${safeToCode(oldArgsType)}`, + `Old returns: ${safeToCode(oldReturnsType)}`, + `New args: ${argsCode}`, + `New returns: ${returnsCode}`, + `Description: ${input.description}`, + ``, + `## How parameters work`, + ``, + `Parameters are bound under \`args\` — read \`args.\` via \`{ kind: "get", path: [{ prop: "args" }, { prop: "" }] }\`. Available params: ${paramList}.`, + ``, + `## Recursion via \`recurse\``, + ``, + `\`recurse\` is the (new) function bound for self-calls. Path: \`{ kind: "get", path: [{ prop: "recurse" }, { args: { ... } }] }\`.`, + ``, + `## Steps`, + ``, + `1. \`write({ program: })\`.`, + `2. \`test({ args: { ${paramNames.map((p) => `${p}: `).join(', ')} } })\`.`, + `3. \`finish({ saveAs: '${input.name}' })\` once the test passes — overwrites the existing on-disk fn.`, + ].join('\n'); + + const messages: Message[] = [{ role: 'user', content: request }]; + const childDepth = (ctx.programmerDepth ?? 0) + 1; + const innerRunState = createRunState(); + const innerCtx = { + ...ctx, + messages, + programmerDepth: childDepth, + programmerChain: innerChain, + runState: innerRunState, + targetFn: { + name: input.name, + argsType: newArgsType, + returnsType: newReturnsType, + ...(input.types + ? { + callTypes: input.types, + sourceArgs: input.args, + sourceReturns: input.returns, + } + : {}), + }, + }; + + await runSubagent( + `programmer: ${input.name} edit (depth ${childDepth})`, + () => programmer.get('stream', {}, innerCtx), + ctx.signal, + ); + + if (!innerRunState.lastTest?.success) { + const why = innerRunState.lastTest?.error ?? 'no successful test was recorded'; + return `// FAILED: edit of '${input.name}' did not reach a passing test (${why}). The on-disk fn is UNCHANGED.`; + } + if (!ctx.loadedFns.has(input.name)) { + return `// FAILED: programmer reached a passing test but didn't call finish({ saveAs: '${input.name}' }). The on-disk fn is UNCHANGED.`; + } + return `Function '${input.name}' edited (now ${argsCode} → ${returnsCode}). The new body has been persisted.`; + }, +}); + +function safeToCode(t: { toCode?: () => string; name?: string } | undefined): string { + if (!t) return ''; + try { return (t.toCode?.() ?? t.name) ?? ''; } + catch { return t.name ?? ''; } +} + export const designer = ai.prompt({ name: 'designer', description: 'Design or reuse gin functions — the reusable building blocks of programs.', @@ -306,19 +513,31 @@ When in doubt: \`use\` and \`created\` must reflect what is ACTUALLY available on disk: - Only put a name in \`use\` if \`get_fn\` (or \`search_fns\` + \`get_fn\`) confirmed it exists. -- Only put a name in \`created\` if \`create_new_fn\` returned successfully - for that name in this session. If \`create_new_fn\` raised an error, - the function was NOT written — do NOT claim it as created. The - programmer that consumes your output will load each name from disk - and break if you fabricate entries. +- Only put a name in \`created\` if \`create_new_fn\` OR \`edit_fn\` + returned successfully for that name in this session. If either + raised an error, the function was NOT written — do NOT claim it as + created. The programmer that consumes your output will load each + name from disk and break if you fabricate entries. If you couldn't satisfy the request, return empty arrays and let the programmer write the work inline rather than claiming a non-existent function. +## Edit vs create + +Use \`edit_fn\` when the request is to MODIFY an existing saved fn +(widen its args, narrow its returns, change its body). The edit tool +enforces backwards-compatibility — args may add optional params or +widen existing param types; returns may narrow. If the requested +change would break callers (remove a required arg, narrow arg types, +widen returns), \`edit_fn\` rejects it and you should either tell the +caller it's incompatible OR \`create_new_fn\` under a different name. + +Use \`create_new_fn\` for net-new functionality. + Request: {{description}}`, input: (input: { description: string }) => ({ description: input.description }), - tools: [searchFns, getFn, printFn, createNewFn, ask], + tools: [searchFns, getFn, printFn, createNewFn, editFn, ask], toolIterations: toolIterationsConfig(), excludeMessages: true, schema: z.object({ diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index 716b446..5416e66 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -12,6 +12,7 @@ import { ask } from '../tools/ask'; import { printFn } from '../tools/print-fn'; import { searchFns } from '../tools/search-fns'; import { searchVars } from '../tools/search-vars'; +import { editType } from '../tools/edit-type'; /** * Rebuild a class's canonical instance with `generic` type-parameter @@ -321,7 +322,7 @@ You orchestrate four specialist sub-agents on demand: ## How to respond -Two modes: +Three modes — pick by request shape, not by guess: 1. **Informational / capability questions** (e.g. "what can you do?", "how does ginny work?", "what types do I have?") — answer directly @@ -330,9 +331,65 @@ Two modes: your underlying model. Describe ginny's real capabilities listed above, using the context below (registered types, globals, etc.) as ground truth. -2. **Computational / action requests** (e.g. "add 2 and 3", "fetch X - and do Y", "count done tasks") — use the write → test → finish loop - described below. +2. **Simple computational request** (one-shot computation, single fn, + shape obvious from the request — e.g. "add 2 and 3", "count done + tasks", "fetch X") — use the write → test → finish loop described + below. Don't pause to plan; the work fits in a single iteration. +3. **Complex / multi-piece request** (multiple functions, several + types, a non-trivial workflow, ambiguous scope) — DO NOT start + writing immediately. Follow the plan-and-approve workflow: + +### Plan-and-approve workflow (mode 3) + +A request is "complex" when ANY of these hold: +- The natural decomposition is more than one function. +- More than one new type / var would need to exist. +- The shape of the user's data isn't obvious from the prompt (what + fields? what optionality? what enum values?). +- Behavior is conditional / configurable in ways the user hasn't + specified. +- The result is a small system (e.g. "build me a todo CLI with + priorities and due-date sorting"), not a one-shot computation. + +When you detect complexity, **stop and plan** before any +write/test/finish: + +1. **Ask clarifying questions FIRST.** Use the \`ask\` tool — one + question at a time, focused on the gaps that would otherwise force + you to guess. Don't fabricate constraints; if the user said "store + tasks" you don't know whether they want them on disk, in a var, or + pure in-memory — ask. Group related questions into a short batch + (3–4 max per turn) so the user isn't drip-fed; if more come up + after they answer, ask another batch. +2. **Produce a written plan.** Once the gaps are filled, respond with + plain prose laying out: + - **Summary** of what you understood the user wants. + - **Types** to be created — name + brief shape for each. + - **Functions** to be created — name, signature + (\`(args): returns\`), and a one-line role. + - **Vars** if any — name + purpose. + - **Open questions or assumptions** that the user should + confirm/correct. + - End with a clear "Does this match what you want? Anything to + adjust?" — invite the user to refine or reject pieces. +3. **Iterate the plan.** When the user replies with corrections, + produce an updated plan in the same shape. Don't start + write/test/finish yet. Keep refining until the user signals + approval ("looks good", "go ahead", "ship it", etc.). +4. **Only then implement.** When approval is explicit, execute the + plan via the normal write → test → finish loop, working through + the planned types / fns / vars in order. The plan is your spec; + don't drift from it. If a piece turns out to need a change you + didn't anticipate, surface it back to the user as a small + amendment ("I need to add X to make Y work; OK?") instead of + silently expanding scope. + +The cost of a wrong upfront plan is small (an extra back-and-forth); +the cost of building the wrong system from scratch is big. + +For mode-2 simple requests, you DO NOT need this dance. Going through +plan/approve for "add 2 and 3" is annoying overhead — just compute it. +The shape of the request itself signals which mode applies. ## Gin language overview Gin is a JSON expression language. Programs are expression trees (ExprDef JSON). @@ -351,12 +408,49 @@ ${EXPR_KINDS} ${PATH_EXPLANATION} ## Globals always available -- \`fns.fetch({ url, method?, headers?, body?, output?: typ }): R\` — HTTP fetch. -- \`fns.llm({ prompt, tools?, output?: typ }): R\` — LLM call. +- \`fns.fetch({ url, method?, headers?, body?, output?: typ }): R\` — HTTP fetch. +- \`fns.llm({ prompt, tools?, output?: typ }): R\` — LLM call. R is constrained to text (free-form replies) or obj (structured outputs); it does NOT default to anything. Choose one and bind it on the call site (see "Generic bindings" below). - \`fns.log({ message: any }): void\` — print a runtime message to the user (stderr). Use for progress narration, intermediate values, debug breadcrumbs. Distinct from the program's return value. -- \`fns.ask({ title: text, details: text, output?: typ }): optional\` — pause execution and prompt the user. With \`output\` set the consumer walks the user through any complex shape (obj fields, list items, choices, optionals). Returns \`null\` (\`optional\`) on cancel — handle that explicitly. +- \`fns.ask({ title: text, details: text, output?: typ }): optional\` — pause execution and prompt the user. With \`output\` set the consumer walks the user through any complex shape (obj fields, list items, choices, optionals). Returns \`null\` (\`optional\`) on cancel — handle that explicitly. - \`vars.*\` — named typed values, persisted on disk. +## Generic bindings — \`\` is a CONSTRAINT, not a default + +When a fn is declared \`fn>(...)\`, the \`\` +is the type that any binding for R must SATISFY (i.e. be assignable +to). It is not a fallback — there is no implicit default. The path +walker requires the binding to come from somewhere; if you don't +supply it explicitly, R stays an unresolved placeholder and downstream +type checks against R will be permissive (and may fail at runtime). + +To bind a generic on a call site, attach a \`generic\` map on the +CallStep alongside \`args\`: + +\`\`\`json +// fns.llm({...}): R — explicitly bind R to a saved +// 'Sentiment' obj type so the return reads as 'Sentiment'. +{ "kind": "get", "path": [ + { "prop": "fns" }, { "prop": "llm" }, + { + "args": { + "prompt": { "kind": "new", "type": { "name": "text" }, "value": "..." }, + "output": { "kind": "new", "type": { "name": "typ" }, "value": { "name": "Sentiment" } } + }, + "generic": { "R": { "name": "Sentiment" } } + } +]} +\`\`\` + +Constraint-violating bindings are rejected at the call site: + +- \`fns.llm\` with \`generic: { R: { name: "num" } }\` → ERROR + (\`num\` doesn't satisfy \`text | obj\`). +- \`fns.llm\` with \`generic: { R: { name: "text" } }\` → OK. +- \`fns.llm\` with \`generic: { R: } }\` → OK. + +The \`\` form (fetch, ask) means "no constraint" — any binding +is accepted. + ## Writing prompt-friendly types for \`fns.ask\` The ask consumer uses each (sub)type's \`docs\` field as the user-facing @@ -637,6 +731,27 @@ Padding with defaults like \`prefix: ""\` adds visual noise. function becomes a callable global, so the user can invoke it directly later. +## Editing existing types / fns + +When the user wants to MODIFY a saved type or fn (rather than create a +new one), use the dedicated edit tools. Both enforce backwards- +compatibility — you can widen, you can't narrow: + +- \`edit_type({ name, def })\` — replace a saved type's definition. + Allowed: add OPTIONAL fields, widen existing field types, loosen + constraints. Rejected: remove fields, add required fields, narrow + field types, change the type class. +- \`edit_fn({ name, args, returns, types?, description })\` — change a + saved function's signature and body. Args may add optional params + or widen existing param types; returns may NARROW. The body is + rewritten from scratch via an inner programmer (same flow as + \`find_or_create_functions\`'s create path). + +If the change you want to make would BREAK existing callers, the edit +tool will reject it and explain why. In that case the right move is +usually to create a NEW fn / type with a different name (existing +callers stay on the old) rather than force-overwrite. + Use \`research\` (when available) to look up external facts — API response schemas, status codes, enum values, anything you can't reliably guess from one sample. Lean on it BEFORE asking the user. @@ -659,6 +774,7 @@ Respond to the most recent user message in light of the prior turns.`, searchFns, searchVars, printFn, + editType, ], dynamic: true, toolIterations: toolIterationsConfig(), diff --git a/packages/ginny/src/tools/edit-type.ts b/packages/ginny/src/tools/edit-type.ts new file mode 100644 index 0000000..a5ac331 --- /dev/null +++ b/packages/ginny/src/tools/edit-type.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import type { TypeDef } from '@aeye/gin'; +import { buildSchemas } from '@aeye/gin'; +import { ai } from '../ai'; + +/** + * Edit a saved type's definition. The proposed new TypeDef must be + * compatible with the old one — every value of the OLD shape must + * still satisfy the NEW shape — so existing callers and saved data + * keep working. + * + * Allowed edits (compatible widening): + * - Adding OPTIONAL fields to an obj. + * - Widening an existing field's type (`y: num` → `y: num | text`). + * - Loosening constraints (`text{minLength=5}` → `text{minLength=2}`), + * subject to the type's own narrow / widen rules. + * + * Rejected edits (would break callers): + * - Removing a required field. + * - Adding a required field. + * - Narrowing a field type (`num` → `num{min=0}` is fine; `num` → + * `text` is not). + * - Changing the underlying type class (e.g. `obj` → `list`). + */ +export const editType = ai.tool({ + name: 'edit_type', + description: 'Replace a saved type with a new definition; rejects breaking changes.', + instructions: + 'Update an existing named type. The new definition is checked against the old: every value of the OLD shape must still satisfy the NEW shape. ' + + 'Use this to widen field types, add optional fields, or loosen constraints. ' + + 'Removing fields, adding REQUIRED fields, or narrowing types is rejected — those would break existing values / callers.', + schema: (ctx) => { + const opts = buildSchemas(ctx.registry); + return z.object({ + name: z.string().describe('Name of the saved type to edit (matches the file at `./types/.json`).'), + def: (opts.Type as z.ZodType).describe( + 'The new TypeDef. Its `name` must match the type being edited; the structural change must be backwards-compatible.', + ), + }); + }, + call: async (input: { name: string; def: TypeDef }, _refs, ctx) => { + let oldDef: TypeDef; + try { + oldDef = ctx.store.readType(input.name); + } catch { + return `// FAILED: type '${input.name}' not found at \`./types/${input.name}.json\`. Use \`find_or_create_types\` to create new types instead.`; + } + + if ((input.def as { name?: string }).name !== input.name) { + return `// FAILED: edit definition's name '${(input.def as { name?: string }).name}' must match the type being edited ('${input.name}').`; + } + + let oldType, newType; + try { + oldType = ctx.registry.parse(oldDef); + } catch (e: unknown) { + return `// FAILED: could not parse the existing on-disk type '${input.name}': ${e instanceof Error ? e.message : String(e)}.`; + } + try { + newType = ctx.registry.parse(input.def); + } catch (e: unknown) { + return `// FAILED: could not parse the proposed new type definition: ${e instanceof Error ? e.message : String(e)}.`; + } + + // Compatibility check: every OLD-typed value must satisfy NEW. + // `newType.compatible(oldType)` returns true when "new accepts every + // value of old" — exactly the condition we want for edit-safety. + if (!newType.compatible(oldType)) { + return ( + `// FAILED: the proposed type for '${input.name}' is NOT a backwards-compatible widening of the existing one.\n` + + `// Old: ${safeToCode(oldType)}\n` + + `// New: ${safeToCode(newType)}\n` + + `// Allowed: add OPTIONAL fields, widen existing field types, loosen constraints.\n` + + `// Rejected: removing fields, adding required fields, narrowing field types, changing the type class.` + ); + } + + try { + ctx.store.writeType(input.def); + } catch (e: unknown) { + return `// FAILED: edit passed compat check but writing to disk threw: ${e instanceof Error ? e.message : String(e)}.`; + } + + return `Type '${input.name}' updated. New surface: ${safeToCode(newType)}`; + }, +}); + +function safeToCode(t: { toCode?: () => string; name?: string } | undefined): string { + if (!t) return ''; + try { return (t.toCode?.() ?? t.name) ?? ''; } + catch { return t.name ?? ''; } +} diff --git a/packages/ginny/src/tools/write.ts b/packages/ginny/src/tools/write.ts index fa2bc2c..99fbf89 100644 --- a/packages/ginny/src/tools/write.ts +++ b/packages/ginny/src/tools/write.ts @@ -22,7 +22,12 @@ export const write = ai.tool({ let code: string; try { - code = ctx.engine.toCode(input.program); + // Suppress inline comments in the user-visible render. The comments + // stay in the saved ExprDef (so `print_fn(name, includeComments:true)` + // can surface them later) but the live terminal view stays + // structural — comment volume is the model's biggest source of + // visual noise during the write→test loop. + code = ctx.engine.toCode(input.program, { includeComments: false }); } catch (e: unknown) { // toCode shouldn't throw for valid ExprDefs, but if the parse // path hits a malformed sub-tree we still want write() to @@ -50,7 +55,16 @@ export const write = ai.tool({ let problemsNote = ''; let problemsCount = 0; try { - const problems = ctx.engine.validate(input.program, scope); + // When authoring a fn body (`targetFn` set), the program runs + // INSIDE the saved fn's call boundary — `return` is legal there, + // even though the body isn't wrapped in a LambdaExpr. Pass + // `inLambda: true` so the validator doesn't warn `flow.outside- + // lambda` on `return`. For a top-level user program (no + // targetFn), defaults stand: `return` warns as before. + const ctxFlags = ctx.targetFn + ? { inLoop: false, inLambda: true } + : undefined; + const problems = ctx.engine.validate(input.program, scope, ctxFlags); problemsCount = problems.list.length; if (problemsCount > 0) { const lines = problems.list.map((p) => { @@ -71,7 +85,7 @@ export const write = ai.tool({ // problem list and threading goes to ginny.log for post-mortem // debugging — keeps the live view scannable while preserving // every detail in the log. - process.stderr.write(`\x1b[2m${code}\x1b[0m\n`); + process.stderr.write(`\n\x1b[2m${code}\x1b[0m\n`); if (problemsCount > 0) { const noun = problemsCount === 1 ? 'problem' : 'problems'; process.stderr.write(`\x1b[31m[${problemsCount} validation ${noun} — see ginny.log for details]\x1b[0m\n`); From cb895b3e9b81c7b912d22de364fcef48ff92c34e Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Thu, 30 Apr 2026 23:47:07 -0400 Subject: [PATCH 07/21] Update README.md --- packages/ginny/README.md | 137 ++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 30 deletions(-) diff --git a/packages/ginny/README.md b/packages/ginny/README.md index 257222d..907b31b 100644 --- a/packages/ginny/README.md +++ b/packages/ginny/README.md @@ -24,6 +24,12 @@ Everything is typed end-to-end. Every write/test/finish cycle happens inside gin's type system — the agent can't produce invalid expressions, and the structured output you get back carries full type information. +For complex requests (multiple types and functions, ambiguous scope, +"build me a small system…") the programmer pauses to ask clarifying +questions, writes a plan listing the types/fns/vars it intends to +create, and waits for your approval before any code is written. Simple +requests like "add 2 and 3" skip the dance. + ## First run ```bash @@ -31,7 +37,7 @@ $ cd my-new-project $ ginny Created /path/to/my-new-project/config.json -Added config.json to .gitignore +Added config.json + ginny.log to .gitignore Populate the file before re-running: At least one AI provider: @@ -44,6 +50,7 @@ Populate the file before re-running: GIN_PROVIDER — optional, preferred provider (openai | openrouter | aws) GIN_MODEL — optional, specific model id GIN_SEARCH_THRESHOLD — optional, corpus size below which search returns all (default 20) + GIN_TOOL_ITERATIONS — optional, max tool-call iterations per prompt run (default 100) Environment variables still win over config.json values. ``` @@ -80,32 +87,40 @@ ginny is a small council of sub-agents, each specialized: ``` - **programmer** — writes a gin `ExprDef`, calls `test()` against it, - and calls `finish()` when a test passes. Has `write / test / finish` - build tools plus the find-or-create tools for pulling in catalog - items, and a `research` tool for factual lookups. -- **architect** — searches `./types/*.json` by keyword (top-10 above a - configurable threshold, or all entries below); returns existing - types or designs new ones. -- **designer** — same pattern over `./fns/*.json`; can recursively - spin up the programmer to implement a brand-new function body. + and `finish()` when a test passes. Has the build tools + (`write` / `test` / `finish`), the find-or-create tools for pulling + in catalog items, an `edit_type` tool for backwards-compatible type + edits, and a `research` tool for factual lookups. +- **architect** — searches `./types/*.json` by keyword (top-N when the + catalog grows past the threshold, or all entries below); returns + existing types or designs new ones. +- **designer** — same pattern over `./fns/*.json`. Has both + `create_new_fn` (new function from scratch — recursively spawns a + programmer to author the body) and `edit_fn` (backwards-compatible + signature change + fresh body). The compat checker accepts widening + edits and rejects breaking ones. - **dba** — same pattern over `./vars/*.json` (typed named values the user or agent can read/write). - **researcher** — wraps `web_search` + `web_get_page`; answers a - natural-language question iteratively and returns `{ answer, sources }`. + natural-language question iteratively and returns + `{ answer, sources }`. ## Persistence Every catalog entry is one JSON file per name. The filename IS the -identity. All four directories are relative to your current working +identity. The three directories are relative to your current working directory: ``` ./types/Task.json # the Task type ./fns/factorial.json # the factorial function ./vars/apiBaseUrl.json # a persistent var (type + value + docs) -./programs/.json # finalized programs from past requests ``` +You can hand-edit any of these between sessions. The next run picks up +your changes. Drop a new file into any of the three directories by +hand and ginny discovers it on the next search. + ### Example: `./vars/apiBaseUrl.json` A var is a `{type, value, docs}` triple — the simplest on-disk shape: @@ -124,36 +139,56 @@ Loaded at use time, `vars.apiBaseUrl` shows up in scope as a typed ### Types and functions `./types/.json` is a `TypeDef` — gin's serialized type -descriptor. `./fns/.json` is a `{type, body}` pair where `type` -is a `function` TypeDef and `body` is an `ExprDef`. See the -[gin README](../gin#core-concepts) for what TypeDef and ExprDef look -like. +descriptor. `./fns/.json` is a `function`-typed `TypeDef` whose +body lives at `call.get` (gin's native callable shape — see +[gin/src/path.ts](../gin/src/path.ts) for how the path walker +dispatches). The top-level `docs` field is the function's description. -You can hand-edit any of these between sessions. The next run picks up -your changes. Drop a new file into any of the four directories by hand -and ginny discovers it on the next search. +See the [gin README](../gin#core-concepts) for what TypeDef and ExprDef +look like. ## Built-in globals Programs always have access to: -- **`fns.fetch({ url, method?, headers?, body?, output?: typ }): R`** +- **`fns.fetch({ url, method?, headers?, body?, output?: typ }): R`** HTTP fetch. When `output` is a gin Type, the response body is parsed through it — type-safe HTTP in one call. -- **`fns.llm({ prompt, tools?, output?: typ }): R`** - LLM invocation. Pass a gin Type as `output` to get structured, - typed output. +- **`fns.llm({ prompt, tools?, output?: typ }): R`** + LLM invocation. Pass a gin Type as `output` to get structured, typed + output. The `` constraint says R must be either a + text-shaped reply or an obj-shaped structured output — chosen at the + call site. + +- **`fns.log({ message: any }): void`** + Print a runtime message to the user (stderr). Use for progress + narration, intermediate values, or debug breadcrumbs. Distinct from + the program's return value. + +- **`fns.ask({ title: text, details: text, output?: typ }): optional`** + Pause the program and prompt the user. With `output` set the + consumer walks the user through any complex shape (obj fields, list + items, choices, optionals) — every (sub)type's `docs` field becomes + the user-facing label. Returns `null` (`optional`) if the user + cancels, so the program must handle that branch explicitly. - **`vars.`** — any var you've created or imported. +### Generics are constraints, not defaults + +`` declares the constraint a binding for R must +satisfy. The model picks a concrete R at the call site (via the +CallStep's `generic: { R: }` map); there's no implicit default. +Bindings that don't satisfy the constraint are rejected at call time. + ## The write / test / finish loop ``` > compute the factorial of 6 • (programmer calls find_or_create_functions "factorial function") -• (fn designer spins up new programmer → writes recursive gin program) +• (designer spins up a fresh programmer → writes the recursive gin program) • (programmer calls write(program)) • (programmer calls test() → SUCCESS: 720) • (programmer calls finish()) @@ -161,8 +196,31 @@ Programs always have access to: 720 ``` -The programmer can set `expectError: true` on `test()` to verify a -program raises — useful for "divide 1 by 0 and tell me what happens". +`test()` runs the draft program against sample args. The programmer +can set `expectError: true` to verify a program raises — useful for +"divide 1 by 0 and tell me what happens". + +`finish()` accepts an optional `saveAs: ''` to persist +the program as a reusable function — every saved fn becomes a +callable global, so subsequent runs can invoke it directly. + +## Editing existing types and functions + +Two tools cover backwards-compatible edits: + +- **`edit_type({ name, def })`** (programmer) — replace a saved type's + definition. Allowed: add OPTIONAL fields, widen existing field + types, loosen constraints. Rejected: remove fields, add required + fields, narrow field types, change the type class. +- **`edit_fn({ name, args, returns, body })`** (designer) — change a + saved function's signature and body. Args may add optional params + or widen existing param types; returns may NARROW. The body is + rewritten from scratch by an inner programmer. + +Both tools enforce backwards-compat at parse time and reject breaking +changes with a structured error. If a change is genuinely incompatible +the right move is usually to create a new type / fn under a different +name so existing callers keep working. ## Configuration @@ -176,8 +234,15 @@ directory, or from environment variables (env wins on conflict): | `AWS_REGION` | region for AWS Bedrock (default `us-east-1`) | | `TAVILY_API_KEY` | enables the `web_search` tool | | `GIN_PROVIDER` | preferred provider (openai \| openrouter \| aws) | -| `GIN_MODEL` | pin a specific model id | +| `GIN_MODEL` | pin a specific model id (fallback for any sub-agent without an override) | +| `GIN_PROGRAMMER_MODEL` | model id for the programmer sub-agent | +| `GIN_DESIGNER_MODEL` | model id for the designer (fns) sub-agent | +| `GIN_ARCHITECT_MODEL` | model id for the architect (types) sub-agent | +| `GIN_DBA_MODEL` | model id for the dba (vars) sub-agent | +| `GIN_RESEARCHER_MODEL` | model id for the researcher sub-agent | +| `GIN_LLM_MODEL` | model id for the in-program `fns.llm` calls | | `GIN_SEARCH_THRESHOLD` | corpus size below which catalog search returns all entries (default 20) | +| `GIN_TOOL_ITERATIONS` | max tool-call iterations per prompt run (default 100) | ### AWS Bedrock @@ -202,6 +267,15 @@ ginny: providers enabled → openai, aws + web_search (tavily) At least one provider must resolve. Tavily is optional — without it the programmer still has `web_get_page` (fetch + strip HTML). +## Logging + +Each session writes a verbose timeline to `./ginny.log` (truncated on +startup). Tool inputs and outputs, full validation problems, full +zod parse errors, and stack traces all land in the log; the terminal +view stays compact (one line per error, capped at 200–4096 chars +depending on context). When something goes sideways, `ginny.log` is +where to look. + ## Example sessions ``` @@ -215,10 +289,13 @@ the programmer still has `web_get_page` (fetch + strip HTML). → programmer reads vars.apiBaseUrl, returns the string. > define a Task type with title, done, due - → type designer creates ./types/Task.json (extending obj with props). + → architect creates ./types/Task.json (extending obj with props). > create a program that counts done tasks from a list of tasks → programmer emits a list.filter + .length program using Task. + +> add an `assignee` field to Task (optional) + → programmer calls edit_type — backwards-compatible widening accepted. ``` ## Building from source @@ -247,8 +324,8 @@ provides: ginny provides: - the AI wiring (provider selection, model override, per-request context) -- the sub-agent orchestration (type / fn / vars designers, programmer) -- the CWD-relative catalog (types / fns / vars / programs directories) +- the sub-agent orchestration (architect / designer / dba / researcher / programmer) +- the CWD-relative catalog (types / fns / vars directories) - the REPL and one-shot CLI entry point If you want to embed the same capabilities in your own application From c4081ab60d03e467ea1d8e9170089d8644337feb Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Thu, 30 Apr 2026 23:50:19 -0400 Subject: [PATCH 08/21] Update README.md --- packages/ginny/README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/ginny/README.md b/packages/ginny/README.md index 907b31b..f09aa14 100644 --- a/packages/ginny/README.md +++ b/packages/ginny/README.md @@ -175,13 +175,6 @@ Programs always have access to: - **`vars.`** — any var you've created or imported. -### Generics are constraints, not defaults - -`` declares the constraint a binding for R must -satisfy. The model picks a concrete R at the call site (via the -CallStep's `generic: { R: }` map); there's no implicit default. -Bindings that don't satisfy the constraint are rejected at call time. - ## The write / test / finish loop ``` From 68c556fc55781698ecb9a5d64c3d8130bb7d5f44 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Fri, 1 May 2026 06:11:53 -0400 Subject: [PATCH 09/21] Add type augmentation & reshape loop yield API Introduce Registry.augment to let callers add props/get/call/init to named types (merged across calls; props are additive, get/call/init are first-wins). Wire augmentation into Type.props so augmented props are visible at runtime/static analysis and code rendering. Reshape the loop `yield` into a path-shaped callable that accepts a single args object {key, value}. Update runLoop to provide a Value-wrapped callable and add natives.helpers.setupYield to efficiently build per-iteration callers with correct key/value types. Replace ad-hoc yield usage across native iterators (list, map, obj, tuple, text, num) to use setupYield and preserve accurate argument types, avoiding per-iteration type allocations. Add extensive tests: empirical parallel/concurrency checks (gaps-parallel), loop coverage across map/obj/text (loop-coverage), and registry augmentation behavior (registry-augment) including init/call/get augmentation, schema shaping, and merging semantics. --- .../gin/src/__tests__/gaps-parallel.test.ts | 162 ++++++++ .../gin/src/__tests__/loop-coverage.test.ts | 389 ++++++++++++++++++ .../src/__tests__/registry-augment.test.ts | 341 +++++++++++++++ packages/gin/src/exprs/loop.ts | 20 +- packages/gin/src/natives/helpers.ts | 32 +- packages/gin/src/natives/list.ts | 10 +- packages/gin/src/natives/map.ts | 13 +- packages/gin/src/natives/num.ts | 7 +- packages/gin/src/natives/obj.ts | 14 +- packages/gin/src/natives/text.ts | 8 +- packages/gin/src/natives/tuple.ts | 14 +- packages/gin/src/registry.ts | 84 +++- packages/gin/src/type.ts | 46 ++- packages/gin/src/types/color.ts | 6 +- packages/gin/src/types/duration.ts | 9 +- 15 files changed, 1121 insertions(+), 34 deletions(-) create mode 100644 packages/gin/src/__tests__/loop-coverage.test.ts create mode 100644 packages/gin/src/__tests__/registry-augment.test.ts diff --git a/packages/gin/src/__tests__/gaps-parallel.test.ts b/packages/gin/src/__tests__/gaps-parallel.test.ts index e9e212b..a2d0514 100644 --- a/packages/gin/src/__tests__/gaps-parallel.test.ts +++ b/packages/gin/src/__tests__/gaps-parallel.test.ts @@ -107,3 +107,165 @@ describe('Loop.parallel — concurrency + rate', () => { expect(v.raw).toBe(5); }); }); + +/** + * Empirical concurrency tests — bodies that actually take time, with + * a probe native that records max in-flight count and total wall time. + * Asserts what the parallel orchestration in `LoopExpr.evaluate` + * actually does: with `concurrent: N`, up to N bodies run at once; + * with `rate: ms`, starts are paced; sequential mode never overlaps. + * + * The probe native blocks on `setTimeout(50ms)` and bumps a shared + * counter — same trick a fans-out HTTP client would exercise. Because + * the work is real wall-clock time, the timing assertions have a + * generous lower bound (parallelism MUST cut sequential time roughly + * by N) and a loose upper bound (CI variance is real). + */ +describe('LoopExpr.parallel — empirical concurrency', () => { + function setupProbe(): { + register: (e: import('../index').Engine) => void; + maxInFlight: () => number; + totalCalls: () => number; + reset: () => void; + } { + let inFlight = 0; + let max = 0; + let total = 0; + return { + register(e) { + e.registry.setNative('test.busy', async (_scope, reg) => { + inFlight++; + if (inFlight > max) max = inFlight; + total++; + // 50ms is enough overlap to be measurable across CI without + // making a 4-iteration test painfully slow. + await new Promise((r) => setTimeout(r, 50)); + inFlight--; + return reg.void().parse(undefined); + }); + }, + maxInFlight: () => max, + totalCalls: () => total, + reset: () => { inFlight = 0; max = 0; total = 0; }, + }; + } + + test('sequential loop never overlaps — max in-flight = 1', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3, 4] }, + // No `parallel` field → sequential path. Each body awaits + // before the next yield — so test.busy never overlaps. + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(4); + expect(probe.maxInFlight()).toBe(1); + // 4 × 50ms = 200ms minimum. Lower bound generous to absorb timer slop. + expect(elapsed).toBeGreaterThanOrEqual(180); + }); + + test('parallel concurrent=4 over 4 items — all 4 in-flight simultaneously', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3, 4] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 4 } }, + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(4); + // Concurrency upper bound = 4 — the actual peak should reach 4. + expect(probe.maxInFlight()).toBe(4); + // 4 bodies of 50ms running fully in parallel = ~50ms total. Allow + // up to 150ms before flagging — anything close to 200ms means the + // pool serialised them. + expect(elapsed).toBeLessThan(150); + }); + + test('parallel concurrent=2 over 6 items — peak in-flight clamps at 2', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3, 4, 5, 6] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(6); + // The pool caps active tasks at 2 — should never exceed that. + expect(probe.maxInFlight()).toBe(2); + // 6 bodies of 50ms with concurrency 2 = 3 batches × 50ms = ~150ms. + // Lower bound 130ms (allow timer slop), upper bound 250ms (catch + // accidental serialisation = 300ms). + expect(elapsed).toBeGreaterThanOrEqual(130); + expect(elapsed).toBeLessThan(250); + }); + + test('parallel rate=80ms paces iteration starts even at high concurrency', async () => { + const r = createRegistry(); + const e = new Engine(r); + const probe = setupProbe(); + probe.register(e); + + const program = { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [1, 2, 3] }, + // Concurrency unbounded, but every start is at least 80ms + // after the previous. Three iterations → ~160ms wall time + // (start gaps) + 50ms body for the last one ≈ 210ms+. + parallel: { rate: { kind: 'new', type: { name: 'duration' }, value: { ms: 80 } } }, + body: { kind: 'native', id: 'test.busy' }, + }, + ], + } as const; + const start = Date.now(); + await e.run(program); + const elapsed = Date.now() - start; + + expect(probe.totalCalls()).toBe(3); + // First start: ~0ms. Second: ~80ms. Third: ~160ms. Last body + // finishes ~50ms after that → ≥ 210ms. + expect(elapsed).toBeGreaterThanOrEqual(180); + }); +}); diff --git a/packages/gin/src/__tests__/loop-coverage.test.ts b/packages/gin/src/__tests__/loop-coverage.test.ts new file mode 100644 index 0000000..c519a55 --- /dev/null +++ b/packages/gin/src/__tests__/loop-coverage.test.ts @@ -0,0 +1,389 @@ +import { describe, test, expect } from 'vitest'; +import { primitives } from './_utils'; +import { createRegistry, Engine } from '../index'; + +/** + * Loop coverage for every type that exposes `get().loop` (or + * `get().loopDynamic`). Each type gets: + * + * - a sequential test verifying iteration ORDER and the per-step + * `key` / `value` bindings + * - a parallel test verifying every iteration executes when the + * parallel options are set (where parallel is meaningful) + * + * `list` and `num` are covered in `exprs-loop-flow.test.ts` / + * `gaps-parallel.test.ts`; `bool` (while-loop semantics) lives in + * `loop-while-bool.test.ts`. This file fills the gaps for `map`, + * `obj`, and `text`. + */ +describe('LoopExpr — coverage for map / obj / text', () => { + describe('map', () => { + test('sequential iteration yields every entry', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'm', + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } }, + value: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + { key: 'c', value: 3 }, + ], + }, + }, + { name: 'sum', value: { kind: 'new', type: { name: 'num' }, value: 0 } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'm' }] }, + body: { + kind: 'set', + path: [{ prop: 'sum' }], + value: { + kind: 'get', + path: [ + { prop: 'sum' }, { prop: 'add' }, + { args: { other: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'sum' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(1 + 2 + 3); + }); + + test('keys come through as the map key type', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'm', + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } }, + value: [ + { key: 'x', value: 10 }, + { key: 'y', value: 20 }, + ], + }, + }, + { name: 'collected', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'm' }] }, + body: { + kind: 'get', + path: [ + { prop: 'collected' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'key' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'collected' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v).sort()).toEqual(['x', 'y']); + }); + + test('parallel concurrent=2 still iterates every entry', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'm', + value: { + kind: 'new', + type: { name: 'map', generic: { K: { name: 'text' }, V: { name: 'num' } } }, + value: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + { key: 'c', value: 3 }, + { key: 'd', value: 4 }, + ], + }, + }, + { name: 'out', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'm' }] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { + kind: 'get', + path: [ + { prop: 'out' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'out' }, { prop: 'length' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(4); + }); + }); + + describe('obj', () => { + test('sequential iteration walks every field as (name, value)', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'o', + value: { + kind: 'new', + type: { + name: 'obj', + props: { + a: { type: { name: 'num' } }, + b: { type: { name: 'num' } }, + c: { type: { name: 'num' } }, + }, + }, + value: { a: 10, b: 20, c: 30 }, + }, + }, + { name: 'sum', value: { kind: 'new', type: { name: 'num' }, value: 0 } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'o' }] }, + body: { + kind: 'set', + path: [{ prop: 'sum' }], + value: { + kind: 'get', + path: [ + { prop: 'sum' }, { prop: 'add' }, + { args: { other: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'sum' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(60); + }); + + test('iteration keys are the field names', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'o', + value: { + kind: 'new', + type: { + name: 'obj', + props: { + alpha: { type: { name: 'num' } }, + beta: { type: { name: 'num' } }, + }, + }, + value: { alpha: 1, beta: 2 }, + }, + }, + { name: 'names', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'o' }] }, + body: { + kind: 'get', + path: [ + { prop: 'names' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'key' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'names' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v).sort()).toEqual(['alpha', 'beta']); + }); + + test('parallel concurrent=2 over an obj still hits every field', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { + name: 'o', + value: { + kind: 'new', + type: { + name: 'obj', + props: { + a: { type: { name: 'num' } }, + b: { type: { name: 'num' } }, + c: { type: { name: 'num' } }, + }, + }, + value: { a: 1, b: 2, c: 3 }, + }, + }, + { name: 'out', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'o' }] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { + kind: 'get', + path: [ + { prop: 'out' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'out' }, { prop: 'length' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(3); + }); + }); + + describe('text', () => { + test('sequential iteration yields each character in order', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { name: 's', value: { kind: 'new', type: { name: 'text' }, value: 'abc' } }, + { name: 'collected', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 's' }] }, + body: { + kind: 'get', + path: [ + { prop: 'collected' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'collected' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v)).toEqual(['a', 'b', 'c']); + }); + + test('iteration keys are 0-based indices', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { name: 's', value: { kind: 'new', type: { name: 'text' }, value: 'xy' } }, + { name: 'indices', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'num' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 's' }] }, + body: { + kind: 'get', + path: [ + { prop: 'indices' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'key' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'indices' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(primitives(v)).toEqual([0, 1]); + }); + + test('parallel concurrent=2 over text still iterates every character', async () => { + const r = createRegistry(); + const e = new Engine(r); + const program = { + kind: 'define', + vars: [ + { name: 's', value: { kind: 'new', type: { name: 'text' }, value: 'abcde' } }, + { name: 'out', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 's' }] }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 2 } }, + body: { + kind: 'get', + path: [ + { prop: 'out' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'out' }, { prop: 'length' }] }, + ], + }, + } as const; + const v = await e.run(program); + expect(v.raw).toBe(5); + }); + }); +}); diff --git a/packages/gin/src/__tests__/registry-augment.test.ts b/packages/gin/src/__tests__/registry-augment.test.ts new file mode 100644 index 0000000..3a5e19f --- /dev/null +++ b/packages/gin/src/__tests__/registry-augment.test.ts @@ -0,0 +1,341 @@ +import { describe, test, expect } from 'vitest'; +import { createRegistry, Engine, Call, GetSet, Init, val, Value } from '../index'; + +/** + * `Registry.augment(name, { props?, get?, call?, init? })` lets a dev + * extend an existing built-in or registered type WITHOUT subclassing + * or wrapping it in an Extension. The added surface flows through + * `Type.props` / `Type.get` / `Type.call` / `Type.init`, so it shows + * up at runtime path-walks, in static type analysis, and in + * `toCodeDefinition` rendering. + */ +describe('Registry.augment', () => { + test('add a method to text — visible via path access', async () => { + const r = createRegistry(); + const e = new Engine(r); + r.augment('text', { + props: { shout: r.method({}, r.text(), 'text.shout') }, + }); + r.setNative('text.shout', (scope, reg) => { + const self = scope.get('this')!.raw as string; + return val(reg.text(), self.toUpperCase() + '!'); + }); + + const program = { + kind: 'get', + path: [{ prop: 's' }, { prop: 'shout' }, { args: {} }], + } as const; + const result = await e.run(program, { s: r.text().parse('hello') }); + expect(result.raw).toBe('HELLO!'); + }); + + test('augmented prop is visible in toCodeDefinition', () => { + const r = createRegistry(); + r.augment('text', { + props: { shout: r.method({}, r.text(), 'text.shout') }, + }); + const def = r.text().toCodeDefinition(); + expect(def).toMatch(/shout\(\): text/); + }); + + test('augmented props do NOT override intrinsic — `num.add` stays intact', () => { + const r = createRegistry(); + r.augment('num', { + // attempt to override num.add with a wrong-shape stub + props: { add: r.method({}, r.text(), 'fake.add') }, + }); + const num = r.num(); + const add = num.prop('add'); + expect(add?.type.call?.()?.returns?.name).toBe('num'); + }); + + test('add `get` to a type that has none — date becomes iterable', async () => { + const r = createRegistry(); + const e = new Engine(r); + // Loop yields three consecutive days starting from `this`. + // `yield` is path-shaped: takes a single `{key, value}` args Value. + r.setNative('date.dayLoop', async (scope, reg) => { + const self = scope.get('this')!.raw as Date; + const yieldFn = scope.get('yield')!.raw as (args: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + const dateType = reg.date(); + const argsType = reg.obj({ + key: { type: indexType }, value: { type: dateType }, + }); + const start = self.getTime(); + for (let i = 0; i < 3; i++) { + await yieldFn(new Value(argsType as any, { + key: val(indexType, i), + value: val(dateType, new Date(start + i * 86400_000)), + } as any)); + } + return val(reg.void(), undefined); + }); + r.augment('date', { + get: new GetSet({ + key: r.num({ whole: true, min: 0 }), + value: r.date(), + loop: { kind: 'native', id: 'date.dayLoop' }, + }), + }); + + // date now reports a `get`/`loop` surface. + const dateGet = r.date().get(); + expect(dateGet).toBeDefined(); + expect(dateGet?.loop).toEqual({ kind: 'native', id: 'date.dayLoop' }); + + // Run a loop that collects the iteration count via a counter. + const program = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [ + { name: 'count', value: { kind: 'new', type: { name: 'num', options: { whole: true, min: 0 } }, value: 0 } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'get', path: [{ prop: 'd' }] }, + body: { + kind: 'set', + path: [{ prop: 'count' }], + value: { + kind: 'get', + path: [ + { prop: 'count' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 1 } } }, + ], + }, + }, + }, + { kind: 'get', path: [{ prop: 'count' }] }, + ], + }, + }, + ], + } as const; + const result = await e.run(program, { + d: r.date().parse(new Date('2026-01-01')), + }); + expect(result.raw).toBe(3); + }); + + test('add `call` to a type that has none — make timestamp callable', () => { + const r = createRegistry(); + r.augment('timestamp', { + call: new Call({ + args: r.obj({ offsetDays: { type: r.num() } }) as any, + returns: r.timestamp(), + }), + }); + const ts = r.timestamp(); + const call = ts.call(); + expect(call).toBeDefined(); + expect(call?.returns?.name).toBe('timestamp'); + }); + + test('augmented `init` — `new (args)` invokes the init expression', async () => { + // `text` doesn't have a native init. Augment it with one that + // formats `{name, count}` args into a custom string. + const r = createRegistry(); + const e = new Engine(r); + r.setNative('text.greet.init', (scope, reg) => { + const args = scope.get('args')!.raw as Record; + const name = args['name']!.raw as string; + const count = args['count']!.raw as number; + return val(reg.text(), `Hello ${name} x${count}`); + }); + r.augment('text', { + init: new Init({ + args: r.obj({ + name: { type: r.text() }, + count: { type: r.num({ whole: true, min: 1 }) }, + }) as any, + run: { kind: 'native', id: 'text.greet.init' }, + }), + }); + + // `new text { name: "World", count: 3 }` should call init.run with + // `args` bound and return its result as a text Value. + const program = { + kind: 'new', + type: { name: 'text' }, + value: { name: 'World', count: 3 }, + } as const; + const result = await e.run(program); + expect(result.raw).toBe('Hello World x3'); + }); + + test('augmentation is also picked up by Extensions over the augmented type', () => { + const r = createRegistry(); + r.augment('num', { + props: { stamp: r.method({}, r.text(), 'num.stamp') }, + }); + const positiveInt = r.extend(r.num({ whole: true, min: 1 }), { + name: 'PositiveInt', + }); + r.register(positiveInt); + expect(positiveInt.prop('stamp')).toBeDefined(); + }); + + test('custom loop Expr (non-native) — augmented type drives iteration via path-callable yield', async () => { + // Augment `num` with a SECOND loop shape via Extension — actually, + // simpler: register a fresh `Pair` type whose `loop` is a plain + // `block` that calls `yield(...)` twice via path. The path-callable + // yield (an obj `{key, value}` arg) is what makes a non-native + // loop ExprDef expressible. This is THE thing custom loops need: + // a path-shaped yield Value sitting in scope. + const r = createRegistry(); + const e = new Engine(r); + + // A custom loop ExprDef — a `block` of two `get` paths that each + // call `yield({ key: , value: })`. No natives involved. + const customLoop = { + kind: 'block', + lines: [ + { + kind: 'get', + path: [ + { prop: 'yield' }, + { + args: { + key: { kind: 'new', type: { name: 'num' }, value: 0 }, + value: { kind: 'new', type: { name: 'text' }, value: 'first' }, + }, + }, + ], + }, + { + kind: 'get', + path: [ + { prop: 'yield' }, + { + args: { + key: { kind: 'new', type: { name: 'num' }, value: 1 }, + value: { kind: 'new', type: { name: 'text' }, value: 'second' }, + }, + }, + ], + }, + ], + }; + + // Augment `text` with a loop that yields these two pairs whenever + // any text Value is iterated. (Replacing text's intrinsic + // `text.chars` is fine here because augmentation only fills gaps — + // text already has `get`, so this augmentation's `get` is dead; + // pick a type that has NONE instead.) + r.augment('null', { + get: new GetSet({ + key: r.num({ whole: true, min: 0 }), + value: r.text(), + loop: customLoop as any, + }), + }); + + // Run a loop over null. The custom loop should yield two pairs. + const program = { + kind: 'define', + vars: [ + { name: 'collected', value: { kind: 'new', type: { name: 'list', generic: { V: { name: 'text' } } }, value: [] } }, + ], + body: { + kind: 'block', + lines: [ + { + kind: 'loop', + over: { kind: 'new', type: { name: 'null' } }, + body: { + kind: 'get', + path: [ + { prop: 'collected' }, { prop: 'push' }, + { args: { value: { kind: 'get', path: [{ prop: 'value' }] } } }, + ], + }, + }, + { kind: 'get', path: [{ prop: 'collected' }] }, + ], + }, + } as const; + const v = await e.run(program); + const raw = (v.raw as Value[]).map((x) => x.raw); + expect(raw).toEqual(['first', 'second']); + }); + + test('augmented `init` shapes the `new` value schema', () => { + // The `value` slot of a `new T(args)` expression should match + // `init.args` whenever T has init defined — augmented or + // intrinsic. Verified by inspecting the Zod shape of the + // type's `toNewSchema()`. + const r = createRegistry(); + r.augment('text', { + init: new Init({ + args: r.obj({ + name: { type: r.text() }, + count: { type: r.num({ whole: true, min: 1 }) }, + }) as any, + run: { kind: 'native', id: 'text.greet.init' }, + }), + }); + + // Build a synthetic SchemaOptions just rich enough for toNewSchema. + const opts = { + Type: r.any() as any, + Expr: r.any() as any, + types: [], + exprs: [], + registry: r, + } as any; + + const schema = r.text().toNewSchema(opts); + // Should accept the init.args shape (and reject mismatched). + expect(schema.safeParse({ name: 'World', count: 3 }).success).toBe(true); + expect(schema.safeParse('plain string').success).toBe(false); + expect(schema.safeParse({ name: 'World' }).success).toBe(false); // count missing + }); + + test('intrinsic init also flows through (duration, color)', async () => { + // `duration.init.args` is `{days?, hours?, minutes?, seconds?, ms?}`. + // After the base `Type.toNewSchema` change, both static AND instance + // schemas should reflect this — not the legacy bare-number form. + const r = createRegistry(); + const opts = { + Type: r.any() as any, Expr: r.any() as any, + types: [], exprs: [], registry: r, + } as any; + const dSchema = r.duration().toNewSchema(opts); + expect(dSchema.safeParse({ days: 1, hours: 2 }).success).toBe(true); + expect(dSchema.safeParse(1234).success).toBe(false); + + // Color too — init.args is {r, g, b, a?}. + const cSchema = r.color().toNewSchema(opts); + expect(cSchema.safeParse({ r: 255, g: 0, b: 0 }).success).toBe(true); + expect(cSchema.safeParse(0xff0000ff).success).toBe(false); + }); + + test('repeated augment calls MERGE props, get/call/init are first-wins', () => { + const r = createRegistry(); + r.augment('text', { props: { a: r.method({}, r.text(), 'a.id') } }); + r.augment('text', { props: { b: r.method({}, r.text(), 'b.id') } }); + const props = r.text().props(); + expect(props['a']).toBeDefined(); + expect(props['b']).toBeDefined(); + + // First `init` wins; second is silently dropped. + const init1 = new Init({ + args: r.obj({}) as any, + run: { kind: 'native', id: 'init.first' }, + }); + const init2 = new Init({ + args: r.obj({}) as any, + run: { kind: 'native', id: 'init.second' }, + }); + r.augment('text', { init: init1 }); + r.augment('text', { init: init2 }); + const eff = r.text().init(); + expect(eff?.run).toEqual({ kind: 'native', id: 'init.first' }); + }); +}); diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 5241bd1..538b52d 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -350,8 +350,24 @@ async function runLoop( over: Value, yieldFn: (k: Value, v: Value) => Promise, ): Promise { - const yieldType = engine.registry.fn(engine.registry.obj({}), engine.registry.void()); - const yieldValue = new Value(yieldType, yieldFn); + // `yield` in the loop scope is a callable Value with args + // `obj({key, value})` and void return. The Value form is what makes + // it usable from a CUSTOM loop ExprDef (e.g. a `block`/`lambda` + // written by a dev that augments a type with their own iteration + // shape) — path-walker call sites pass a single args-obj Value, so + // yield's signature has to match. Native loop impls receive the + // same Value via `scope.get('yield')` and unwrap the two fields. + const r = engine.registry; + const yieldType = r.fn( + r.obj({ key: { type: r.any() }, value: { type: r.any() } }), + r.void(), + ); + const wrappedYield = async (argsValue: Value): Promise => { + const fields = argsValue.raw as Record | null | undefined; + if (!fields) throw new Error('yield: missing args'); + return yieldFn(fields['key']!, fields['value']!); + }; + const yieldValue = new Value(yieldType, wrappedYield); const loopScope = scope.child({ this: over, yield: yieldValue }); try { await engine.evaluate(loopExpr, loopScope); diff --git a/packages/gin/src/natives/helpers.ts b/packages/gin/src/natives/helpers.ts index a5e67b8..65de30b 100644 --- a/packages/gin/src/natives/helpers.ts +++ b/packages/gin/src/natives/helpers.ts @@ -1,5 +1,6 @@ import type { Scope } from '../scope'; -import type { Value } from '../value'; +import { Value } from '../value'; +import type { Registry } from '../registry'; /** Extract `this` (the receiver) from a native's scope. */ export function self(scope: Scope): T { @@ -39,3 +40,32 @@ export function epsilon(scope: Scope): number { function isValue(x: unknown): boolean { return !!x && typeof x === 'object' && 'type' in (x as object) && 'raw' in (x as object); } + +/** + * Build a per-iteration `yield(key, value)` callable for a loop native. + * + * The `yield` Value in scope is path-shaped — it takes a single + * args-obj `{key, value}` Value, so it works for native loops AND + * for custom loop ExprDefs a dev writes against an augmented type + * (see `runLoop` in `exprs/loop.ts`). This helper closes over the + * args Type ONCE so the inner per-iteration call is a thin wrapper + * that just packs the two values — no `reg.obj(...)` allocation per + * iteration. + * + * Pass the concrete key / value Types so consumers downstream see + * accurate type metadata, not `any` placeholders. + */ +export function setupYield( + scope: Scope, + registry: Registry, + keyType: { name: string }, + valueType: { name: string }, +): (key: Value, value: Value) => Promise { + const yieldFn = scope.get('yield')!.raw as (args: Value) => Promise; + const argsType = registry.obj({ + key: { type: keyType as any }, + value: { type: valueType as any }, + }); + return (key: Value, value: Value) => + yieldFn(new Value(argsType as any, { key, value } as any)); +} diff --git a/packages/gin/src/natives/list.ts b/packages/gin/src/natives/list.ts index 1686817..8b93733 100644 --- a/packages/gin/src/natives/list.ts +++ b/packages/gin/src/natives/list.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { ListType } from '../types/list'; -import { arg, self, selfValue, argValue } from './helpers'; +import { arg, self, selfValue, argValue, setupYield } from './helpers'; const itemType = (scope: any) => (selfValue(scope).type as ListType).item; @@ -31,11 +31,13 @@ export const listNatives: Record = { }, 'list.iterate': async (scope, reg) => { const arr = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + const doYield = setupYield(scope, reg, indexType, itemType(scope)); + const voidValue = val(reg.void(), undefined); for (let i = 0; i < arr.length; i++) { - await yieldFn(val(reg.num({ whole: true, min: 0 }), i), arr[i]!); + await doYield(val(indexType, i), arr[i]!); } - return val(reg.void(), undefined); + return voidValue; }, 'list.at': (scope, reg) => { diff --git a/packages/gin/src/natives/map.ts b/packages/gin/src/natives/map.ts index fac6550..851d739 100644 --- a/packages/gin/src/natives/map.ts +++ b/packages/gin/src/natives/map.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { MapType } from '../types/map'; -import { arg, self, selfValue, argValue } from './helpers'; +import { arg, self, selfValue, argValue, setupYield } from './helpers'; type Entry = [Value, Value]; type MapRaw = Map; @@ -32,9 +32,16 @@ export const mapNatives: Record = { }, 'map.iterate': async (scope, reg) => { const m = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + // The map's K / V types are stored on `selfValue.type.generic`; + // peek at the first stored entry as a quick proxy. If the map + // is empty the loop body never runs so the args type doesn't + // matter — fall back to `any` only in that edge case. + const first = m.values().next().value as [Value, Value] | undefined; + const keyType = first ? first[0].type : reg.any(); + const valueType = first ? first[1].type : reg.any(); + const doYield = setupYield(scope, reg, keyType, valueType); for (const [, [kV, vV]] of m) { - await yieldFn(kV, vV); + await doYield(kV, vV); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/natives/num.ts b/packages/gin/src/natives/num.ts index 42b43ba..1ad4cc6 100644 --- a/packages/gin/src/natives/num.ts +++ b/packages/gin/src/natives/num.ts @@ -1,6 +1,6 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; -import { arg, epsilon, self } from './helpers'; +import { arg, epsilon, self, setupYield } from './helpers'; export const numNatives: Record = { // comparison (value-approx via epsilon) @@ -54,14 +54,15 @@ export const numNatives: Record = { // loop: yields (key=0..|n|-1, value=0-toward-n) 'num.loop': async (scope, reg) => { const n = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const numType = reg.num(); + const doYield = setupYield(scope, reg, numType, numType); const count = Math.abs(Math.trunc(n)); const step = n < 0 ? -1 : 1; for (let i = 0; i < count; i++) { const v = i * step; // Normalize negative zero to positive zero for consistent equality. const safe = Object.is(v, -0) ? 0 : v; - await yieldFn(val(reg.num(), i), val(reg.num(), safe)); + await doYield(val(numType, i), val(numType, safe)); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/natives/obj.ts b/packages/gin/src/natives/obj.ts index 031cb41..a2eea77 100644 --- a/packages/gin/src/natives/obj.ts +++ b/packages/gin/src/natives/obj.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { ObjType } from '../types/obj'; -import { arg, self, selfValue } from './helpers'; +import { arg, self, selfValue, setupYield } from './helpers'; const fields = (scope: any) => (selfValue(scope).type as ObjType).fields; @@ -29,11 +29,19 @@ export const objNatives: Record = { 'object.iterate': async (scope, reg) => { const obj = self>(scope); const fs = fields(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + // Build the per-iteration yielder once with stable types: keys + // are always text (field names), values are the union of the + // obj's field types (or just any when there are no fields). + const keyType = reg.text(); + const fieldTypes = Object.values(fs).map((p: any) => p.type); + const valueType = fieldTypes.length === 0 + ? reg.any() + : fieldTypes.length === 1 ? fieldTypes[0]! : reg.or(fieldTypes); + const doYield = setupYield(scope, reg, keyType, valueType); for (const [name] of Object.entries(fs)) { const stored = obj[name]; if (stored instanceof Value) { - await yieldFn(val(reg.text(), name), stored); + await doYield(val(keyType, name), stored); } } return val(reg.void(), undefined); diff --git a/packages/gin/src/natives/text.ts b/packages/gin/src/natives/text.ts index 4e004d4..3ebaa72 100644 --- a/packages/gin/src/natives/text.ts +++ b/packages/gin/src/natives/text.ts @@ -1,6 +1,6 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; -import { arg, self } from './helpers'; +import { arg, self, setupYield } from './helpers'; export const textNatives: Record = { // field @@ -73,9 +73,11 @@ export const textNatives: Record = { }, 'text.chars': async (scope, reg) => { const s = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + const charType = reg.text({ minLength: 1, maxLength: 1 }); + const doYield = setupYield(scope, reg, indexType, charType); for (let i = 0; i < s.length; i++) { - await yieldFn(val(reg.num({ whole: true, min: 0 }), i), val(reg.text({ minLength: 1, maxLength: 1 }), s[i]!)); + await doYield(val(indexType, i), val(charType, s[i]!)); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/natives/tuple.ts b/packages/gin/src/natives/tuple.ts index 45a3dd0..72e498d 100644 --- a/packages/gin/src/natives/tuple.ts +++ b/packages/gin/src/natives/tuple.ts @@ -1,7 +1,7 @@ import type { NativeImpl } from '../registry'; import { Value, val } from '../value'; import { TupleType } from '../types/tuple'; -import { self, selfValue } from './helpers'; +import { self, selfValue, setupYield } from './helpers'; const elems = (scope: any) => (selfValue(scope).type as TupleType).elements; @@ -26,9 +26,17 @@ export const tupleNatives: Record = { }, 'tuple.iterate': async (scope, reg) => { const arr = self(scope); - const yieldFn = scope.get('yield')!.raw as (k: Value, v: Value) => Promise; + const indexType = reg.num({ whole: true, min: 0 }); + // Tuple's value-side type is the union of element types — use it + // so the yielded value's args carry an honest static type. (Empty + // tuple short-circuits with `any`, but the loop body never runs.) + const elemTypes = arr.map((v) => v.type); + const valueType = elemTypes.length === 0 + ? reg.any() + : elemTypes.length === 1 ? elemTypes[0]! : reg.or(elemTypes); + const doYield = setupYield(scope, reg, indexType, valueType); for (let i = 0; i < arr.length; i++) { - await yieldFn(val(reg.num({ whole: true, min: 0 }), i), arr[i]!); + await doYield(val(indexType, i), arr[i]!); } return val(reg.void(), undefined); }, diff --git a/packages/gin/src/registry.ts b/packages/gin/src/registry.ts index 0247896..ad768b2 100644 --- a/packages/gin/src/registry.ts +++ b/packages/gin/src/registry.ts @@ -1,5 +1,5 @@ import type { ExprDef, TypeDef } from './schema'; -import { Prop, Type } from './type'; +import { Call, GetSet, Init, Prop, type PropSpec, Type } from './type'; import { Extension, type ExtensionLocal } from './extension'; import { type BoolOptions, @@ -113,11 +113,41 @@ export type NativeImpl = (scope: Scope, registry: Registry) => Value | unknown | * to each class's static `from` method, and recurses through nested types * via the same entry point. */ +/** + * Augmentations a developer can attach to a Type by name (e.g. 'num', + * 'text', 'Email'). Stored on the Registry; consulted by every Type's + * `props()` / `get()` / `call()` / `init()` so the additions are + * visible at runtime path-walks, in static analysis, and in code + * rendering — without subclassing or wrapping the type in an + * Extension. + * + * Composition rules: + * - `props`: ADDED to the type's intrinsic props. Intrinsic names + * win on conflict (you can't replace `num.add` via augmentation). + * Use this to introduce NEW methods / fields. + * - `get` / `call` / `init`: applied IFF the type has none of its + * own. Augmentation can introduce a missing surface (e.g. give + * `date` a `get/loop`, make `timestamp` callable, give `text` an + * init constructor) but does not override one the type already + * declares. + * + * Augmentations are accumulated — multiple calls to `registry.augment` + * for the same name MERGE props. The first `get`/`call`/`init` that's + * defined wins (subsequent attempts to set the same field are no-ops). + */ +export interface TypeAugmentation { + props?: Record; + get?: GetSet; + call?: Call; + init?: Init; +} + export class Registry implements TypeBuilder, TypeScope { private readonly classes = new Map(); private readonly namedTypes = new Map(); private readonly natives = new Map(); private readonly exprClasses = new Map(); + private readonly augments = new Map(); // ─── SCOPE INTERFACE ───────────────────────────────────────────────────── /** Registry IS the root scope. */ @@ -138,6 +168,58 @@ export class Registry implements TypeBuilder, TypeScope { return this; } + /** + * Add methods / get / call / init to an existing type by name — + * works for both built-in classes (`'num'`, `'text'`, `'date'`, + * `'timestamp'`, ...) and named instances / Extensions you've + * registered. Repeated calls for the same name MERGE: props are + * accumulated, while get/call/init keep their first non-undefined + * value (subsequent attempts to redefine those are silently + * ignored — augmentations fill gaps, they don't override). + * + * Example — give `date` a `get/loop` so you can iterate over a + * range, and make `timestamp` callable as a fn: + * ```ts + * registry.augment('date', { get: new GetSet({ key: registry.num(), value: registry.date(), loop: ... }) }); + * registry.augment('timestamp', { call: new Call({ args: ..., returns: ... }) }); + * ``` + * + * Augmented surface flows through every consumer: path-walker + * dispatches against augmented `props` / `get` / `call`, + * `validateWalk` static analysis sees them, `toCodeDefinition` + * renders them in the type's surface block. + */ + augment(name: string, addition: TypeAugmentation): this { + const cur = this.augments.get(name); + if (!cur) { + this.augments.set(name, { + props: addition.props ? { ...addition.props } : undefined, + get: addition.get, + call: addition.call, + init: addition.init, + }); + return this; + } + // Merge into the existing augmentation. Props additive (new wins + // on per-name conflict within augmentation itself, but intrinsic + // type props still win at consumption time). get/call/init are + // first-wins — once set, further attempts no-op. + this.augments.set(name, { + props: addition.props ? { ...(cur.props ?? {}), ...addition.props } : cur.props, + get: cur.get ?? addition.get, + call: cur.call ?? addition.call, + init: cur.init ?? addition.init, + }); + return this; + } + + /** Read the registered augmentation for a type-by-name. Returns + * undefined when nothing has been augmented. Used by `Type.props` + * / `Type.get` / `Type.call` / `Type.init` to overlay additions. */ + augmentation(name: string): TypeAugmentation | undefined { + return this.augments.get(name); + } + /** Look up a Type by name. Registered named instances win; falls back * to built-in classes (synthesized canonical instance). Returns * undefined for unknown names. Implements `TypeScope.lookup`. */ diff --git a/packages/gin/src/type.ts b/packages/gin/src/type.ts index a839350..1dd7511 100644 --- a/packages/gin/src/type.ts +++ b/packages/gin/src/type.ts @@ -557,9 +557,18 @@ export abstract class Type implements Node { * `scope` propagates the call-site TypeScope (see `valid`). */ props(_scope?: TypeScope): Record { - return { + // Universal props every type carries. + const base: Record = { toAny: this.registry.method({}, this.registry.any(), 'type.toAny'), }; + // Spread registry-augmentation props BEFORE returning. Subclasses + // override `props()` and prepend `super.props()` to their own — + // so augmentation lands BEFORE the subclass's intrinsic methods, + // i.e. intrinsic wins on name conflict (`num.add` can't be + // accidentally replaced by augmenting `num` with another `add`). + const aug = this.registry.augmentation(this.name); + if (!aug?.props) return base; + return { ...base, ...aug.props }; } /** Names of props defined universally on every Type (via base `props()`). @@ -578,19 +587,28 @@ export abstract class Type implements Node { return []; } - /** Effective GetSet — present iff this type supports [key] access. */ + /** Effective GetSet — present iff this type supports [key] access. + * Falls back to a registry-augmentation when the type itself + * declares none. Augmentation NEVER overrides an intrinsic — it + * only fills the gap. (Subclasses that declare their own `get` + * override this method and don't consult augmentation.) */ get(_scope?: TypeScope): GetSet | undefined { - return undefined; + return this.registry.augmentation(this.name)?.get; } - /** Effective Call — present iff this type is invocable. */ + /** Effective Call — present iff this type is invocable. Augmented + * via `registry.augment(name, { call })` for types that aren't + * natively callable (e.g. making `timestamp` invocable). */ call(_scope?: TypeScope): Call | undefined { - return undefined; + return this.registry.augmentation(this.name)?.call; } - /** Effective Init — present iff this type has a custom constructor. */ + /** Effective Init — present iff this type has a custom constructor. + * Augmented via `registry.augment(name, { init })` for types that + * don't natively define one. When `init` is set on a type, `new T(args)` + * routes through it — see `NewExpr.evaluate`. */ init(_scope?: TypeScope): Init | undefined { - return undefined; + return this.registry.augmentation(this.name)?.init; } /** Convenience over props() — single-name lookup, normalized to Prop. */ @@ -684,9 +702,21 @@ export abstract class Type implements Node { * the Zod shape. So `new obj { x: text, y: num }` accepts * `{ x: , y: }`. * - * Default = `toValueSchema(opts)`; composites override. + * Default behaviour: + * - When the type defines `init()` (a constructor), the value + * slot IS the init's args obj. `new (args)` literally calls + * `init.run` with `args` parsed against `init.args`, so the + * schema the LLM sees should be that args type. + * - Otherwise fall through to `toValueSchema(opts)`. + * + * Composites still override (list / map / obj / tuple / typ / ... + * have richer Expr-slot shapes that don't fit the init mould). */ toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + const init = this.init(); + if (init) { + return this.describeType(init.args.toValueSchema(opts), opts, 'NewValue_'); + } return this.toValueSchema(opts); } diff --git a/packages/gin/src/types/color.ts b/packages/gin/src/types/color.ts index cb746d9..0c9bea8 100644 --- a/packages/gin/src/types/color.ts +++ b/packages/gin/src/types/color.ts @@ -30,8 +30,10 @@ export class ColorType extends Type { }).meta({ aid: 'Type_color' }); } - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { - return z.number().int().min(0).max(0xffffffff); + static toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + // Defer to canonical — base derives the obj shape ({r, g, b, a?}) + // from `init.args`, matching the runtime contract. + return new ColorType(opts.registry, {}).toNewSchema(opts); } valid(raw: unknown): raw is number { diff --git a/packages/gin/src/types/duration.ts b/packages/gin/src/types/duration.ts index e666498..183f8e0 100644 --- a/packages/gin/src/types/duration.ts +++ b/packages/gin/src/types/duration.ts @@ -27,7 +27,14 @@ export class DurationType extends Type> { .meta({ aid: 'Type_duration' }); } - static toNewSchema(_opts: SchemaOptions): z.ZodTypeAny { return z.number(); } + static toNewSchema(opts: SchemaOptions): z.ZodTypeAny { + // Defer to the canonical instance — base `toNewSchema` already + // derives the right shape from `init.args` when a constructor is + // declared, so duration's `new` schema lines up with the runtime + // contract (an obj of {days?, hours?, minutes?, seconds?, ms?}) + // instead of a bare number. + return new DurationType(opts.registry, {}).toNewSchema(opts); + } valid(raw: unknown): raw is number { return typeof raw === 'number' && !Number.isNaN(raw); From c2225a8981c799a312e5218262f593de2abce869 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Fri, 1 May 2026 06:18:49 -0400 Subject: [PATCH 10/21] Revise gin README with detailed type system docs Completely rewrites packages/gin/README.md to replace the previous quick-start and terse overview with a comprehensive, restructured reference. New content documents the type system surfaces (props, get, call, init), generics and TypeScope resolution, type compatibility rules, extensions vs augmentations, the 12 expression kinds, parsing model, the Registry API, and a full built-in type catalog. Updated example shows extension, augmentation, native registration, and engine.run usage. Removes older short examples and test/dump commands in favor of a longer, developer-oriented guide and sample code. --- packages/gin/README.md | 992 +++++++++++++++++++++++++---------------- 1 file changed, 603 insertions(+), 389 deletions(-) diff --git a/packages/gin/README.md b/packages/gin/README.md index fb3afbb..b3ee8fe 100644 --- a/packages/gin/README.md +++ b/packages/gin/README.md @@ -13,466 +13,680 @@ pluggable registry of native functions. npm install @aeye/gin zod ``` -## Why gin? - -LLMs are good at JSON. They're less good at language grammars with -balanced parens, significant whitespace, and rule-based parsers. gin -inverts the traditional approach: - -- **Programs are JSON trees.** Every expression has a `kind` discriminator. - Every type has a `name`. Serialization is free. -- **Types are first-class values.** A `typ` slot accepts any registry - type compatible with `T` — including user-defined extensions — and - narrows its ExprDef schema so the LLM only sees valid choices. -- **Structural + extension typing.** `Task extends obj` inherits obj's - shape and methods while adding new props, constraint predicates, and - overrides. Compatibility is decided by structure, not name. -- **Per-class Zod schemas at every layer.** `toSchema()` schemas the - TypeDef JSON. `toValueSchema()` schemas runtime data. `toNewSchema()` - schemas `new` expressions. `toInstanceSchema()` schemas narrow-match - TypeDef JSON for containers that constrain registered types. The LLM - always gets a tight Zod union of exactly what's valid at that slot. -- **Native functions.** Register any JS/TS function as a `fn<...>` type; - calls from gin programs dispatch to your implementation. - -## Quick start - -`createRegistry()` ships with every built-in type and native -implementation pre-registered. `createEngine(r).run(expr)` evaluates a -program. - -### 1. `let x = 2; return x.add(3)` → `5` +--- + +## The type system + +Every Type — built-in or developer-defined — exposes up to four +surfaces. These are the only knobs you have for shaping runtime +behavior: + +### `props` — named methods and fields + +A type's `props` map is the static surface accessed by name. Each prop +is one of: + +- A **value-typed prop** — `length: num` on `text`, `r: num` on `color`. + Read by walking a path step `{prop: 'length'}`. +- A **method** — `add(other: num): num` on `num`, `slice(start, end?): text` + on `text`. A method is just a prop whose type is a `function` — + invoking it via `[{prop: 'add'}, {args: {other: 3}}]` runs the + underlying expression / native. + +The same path step `{prop: 'name'}` works for both — a method just has +a callable type, so you follow it with a `{args: ...}` step. -```ts -import { createRegistry, createEngine } from '@aeye/gin'; +### `get` — keyed access (and looping) + +When a type defines `get`, it supports `[key]` access. The `GetSet` +spec carries: + +- `key` — the type a key must satisfy (`num` for lists, the field-name + union for `obj`, `text` for `map`, ...). +- `value` — what indexed access produces. +- Optional `loop` expression — drives `loop` iteration. When present, the + type is iterable via `{kind: 'loop', over: , body: ...}`. + The loop expression runs with `this` (the iterable) and `yield` (a + callable taking `{key, value}`) bound in scope, and calls `yield` + once per pair. Native loops live in `gin/src/natives/*.ts`; you can + register custom ones via augmentation. +- Optional `loopDynamic: true` — flags while-loop semantics (the + `over` expression is re-evaluated each iteration). `bool` uses this. + +### `call` — make the type callable + +When a type defines `call`, values of that type can be invoked. The +`Call` spec carries `args` (an obj-shaped type), `returns` (the +result type), optional `throws`, and optional `get`/`set` expressions +that implement the call. `function` is the obvious example, but +augmentation can make any type callable. + +### `init` — constructor for `new` + +When `init` is defined, `{kind: 'new', type: T, value: }` parses +`` against `init.args` and runs `init.run` with `{this, args}` +in scope — `this` is a default-constructed value and `args` is the +parsed input. The expression returns either a fresh value (if the run +returns one) or the mutated `this`. Without `init`, `new T(value)` +just runs `T.parse(value)` directly. `duration` and `color` ship with +init defined; the LLM authors `new color({r: 255, g: 0, b: 0})` and +the constructor packs the channels into a 32-bit integer. + +The `value` slot of a `new` expression automatically reflects +`init.args` in the LLM-facing schema — devs don't write per-type +`toNewSchema` overrides for that case. + +--- + +## Generics -const r = createRegistry(); -const engine = createEngine(r); +A type can declare `generic` parameters — each entry's value is a +**constraint**, not a default. Bare `{name: 'R'}` inside the +signature is an unresolved placeholder (gin's `AliasType`); concrete +resolution happens when a call site supplies a binding. -const program = { - kind: 'define', - vars: [ - { name: 'x', value: { kind: 'new', type: { name: 'num' }, value: 2 } }, - ], - body: { - kind: 'get', - path: [ - { prop: 'x' }, // read x from scope - { prop: 'add' }, // num's `.add` method - { args: { other: { kind: 'new', type: { name: 'num' }, value: 3 } } }, - ], - }, -}; +- `R: any` — no constraint. Any type accepted as a binding. +- `R: text | obj` — bindings must be assignable to `text | obj`. + Anything else is rejected at the call site with a clear error. +- `R: ` — structural constraint. Bindings must satisfy + the interface (every prop / get / call the interface declares + exists on the binding with a compatible type). +- `R: alias('R')` — self-reference. Equivalent to "no constraint"; + the satisfies check is skipped. -const result = await engine.run(program); -console.log(result.raw); // 5 -console.log(result.type.name); // 'num' -``` +Bindings are validated when a `CallStep` provides them. There is no +implicit default — if you don't bind, the parameter stays a +placeholder and downstream type checks against it are permissive. -### 2. Extension types + lambdas + collection methods +Generics show up natively in fn types (`(args): R`), in +parameterized types (`list`, `map`, `optional`), and on +methods that introduce their own type parameters (`list.map(fn): list`). -Count completed tasks in a typed `list`: +--- -```ts -import { createRegistry, createEngine } from '@aeye/gin'; +## Type compatibility -const r = createRegistry(); +`a.compatible(b)` answers "every value of b is also a valid value of +a" — i.e. `b` is assignable to `a`. Used by: -// Declare Task as an obj extension with two typed fields. -const Task = r.extend(r.obj({ - title: { type: r.text({ minLength: 1 }) }, - done: { type: r.bool() }, -}), { name: 'Task', docs: 'An action item in a to-do list' }); -r.register(Task); +- **path validation** — a method call's args must be compatible with + the called fn's args type. +- **structural interface satisfaction** — does this object have all + the props an interface requires? +- **edit safety** — can this new type definition replace the old one + without breaking callers? Check both directions. -const engine = createEngine(r); +For obj specifically: `a.compatible(b)` requires every required +field of `a` to exist on `b` with a compatible per-field type. +Optional fields on `a` may be absent from `b` (the missing field +defaults to undefined, which optional accepts). Extra fields on `b` +are ignored. `opts.exact` tightens this to exact field-set match. -// tasks.filter(t => t.done).length -const program = { - kind: 'define', - vars: [{ - name: 'tasks', - value: { - kind: 'new', - type: { name: 'list', generic: { V: { name: 'Task' } } }, - value: [ - { title: 'ship it', done: true }, - { title: 'write docs', done: false }, - { title: 'deploy', done: true }, - ], - }, - }], - body: { - kind: 'get', - path: [ - { prop: 'tasks' }, - { prop: 'filter' }, - { - args: { - fn: { - kind: 'lambda', - type: { - name: 'function', - call: { args: { name: 'object' }, returns: { name: 'bool' } }, - }, - body: { - // `args.value` is the current Task (filter passes {value, index}). - kind: 'get', - path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'done' }], - }, - }, - }, - }, - { prop: 'length' }, - ], - }, -}; +For fn: bivariant on args (matches TypeScript's default method-arg +rule), covariant on returns. Most code wants the bivariant form; +edit-compat tooling splits args + returns and checks each side +directionally to enforce strict TS-style variance. -const result = await engine.run(program); -console.log(result.raw); // 2 -``` +--- -Everything above round-trips through `JSON.stringify`/`JSON.parse` — -the program, the Task type, every intermediate value. An LLM can -produce the same shape directly. +## Extensions -### 3. Native functions +`registry.extend(base, { name, ... })` creates a named type that +overlays additions on a base. Extensions can: -Hook any JS/TS function into gin's call system by id: +- **Add props** — new fields and methods. +- **Override `get` / `call` / `init`** — replace any of the base's + surfaces. +- **Narrow options** — `Email` extending `text({pattern: ...})` + carries the tighter pattern at runtime. +- **Add a constraint Expr** — a runtime predicate every value must + satisfy. Evaluated on `engine.validateValue(v)`; runs with `this` + bound to the value. +- **Declare `generic`** — extensions can have their own type params. -```ts -import { val } from '@aeye/gin'; +Extensions delegate everything to the base via `Type.compatible`, +`Type.props` composition, etc. `Email extends text` is a real +subtype: every Email is a valid text; tighter tests pass on +Email-only values. -// Override `num.sqrt` so it does the obvious thing. -r.setNative('num.sqrt', (scope, registry) => - val(registry.num(), Math.sqrt((scope.get('this')!.raw as number))), -); +--- -const sqrt16 = { - kind: 'get', - path: [ - { prop: 'n' }, - { prop: 'sqrt' }, - { args: {} }, - ], -}; -const result = await engine.run(sqrt16, { n: val(r.num(), 16) }); -console.log(result.raw); // 4 -``` +## Augmentations -## Core concepts +`registry.augment(name, { props?, get?, call?, init? })` adds to an +EXISTING type by name — works for built-ins (`'num'`, `'text'`, +`'date'`, `'timestamp'`, ...) and registered named types. Augmentation +is gentler than extension: -### `Type` +- `props` are MERGED into the type's existing props. Intrinsic names + win on conflict — you can't override `num.add` by augmenting num. +- `get` / `call` / `init` are applied IFF the type has none of its + own. Augmentation FILLS GAPS — give `date` a `get` so it iterates, + make `timestamp` callable, give `text` a constructor — but never + overrides what's already there. -A `Type` describes the shape of values and exposes the operations on -them. Every type implements: +The augmented surface flows through every consumer: path-walks +dispatch against augmented props; static analysis sees them; code +rendering shows them. No subclassing or wrapper required. -| Method | Purpose | -|---|---| -| `valid(raw, scope?)` | Runtime type guard over the raw value | -| `parse(json, scope?)` | JSON → `Value` (throws on mismatch) | -| `encode(raw, scope?)` | raw → JSON envelope (round-trip-safe) | -| `compatible(other, opts?, scope?)` | structural compatibility check | -| `like(other, scope?)` | narrow self by `other`, recursing through children | -| `simplify(scope?)` | collapse trivial wrappers; AliasType resolves through `scope` | -| `props(scope?)` / `get(scope?)` / `call(scope?)` / `init(scope?)` | expose fields, index access, call signatures, constructors | -| `toCode()` / `toCodeDefinition()` | render TypeScript-like source for the LLM | -| `toSchema(opts)` | Zod schema for the TypeDef JSON | -| `toValueSchema(opts)` | Zod schema for the runtime VALUE | -| `toNewSchema(opts)` | Zod schema for the value side of `{kind:'new'}` | -| `toInstanceSchema()` | Zod schema that narrow-matches TypeDef JSON (used by `typ`) | -| `toJSON()` | serialize the Type itself to a TypeDef | - -### `Value` - -A `Value` pairs a type with a runtime raw payload: +When you want to genuinely REPLACE behavior (not just add), use an +Extension — extensions own their entire surface and can override +freely. -```ts -class Value { - readonly type: Type; - readonly raw: RuntimeOf; - toJSON(): JSONValue; // { type: TypeDef, value: JSONOf } -} -``` +--- -Composites store `Value`-wrapped children so per-element concrete types -survive JSON round-trips — a `Dog` stored in a `list` comes -back as a `Dog`, not widened to `Animal`. +## The 12 expression kinds -### `Expr` +A gin program is a tree of `Expr` JSON objects. Every node has +`kind: '...'` plus the fields that kind declares. -The expression AST. Every node has a `kind` and serializes to -`ExprDef`. The built-in kinds: +### `new` — construct a value of a given type -| Kind | Purpose | -|---|---| -| `new` | Construct a value of a specific type | -| `get` | Read a path (`{prop}`, `{args}`, `{key}`) from scope | -| `set` | Write a path target | -| `define` | Bind local variables in a child scope | -| `block` | Sequence expressions; last value wins | -| `if` | Multi-arm conditional with optional else | -| `switch` | Value discrimination with `equals` patterns | -| `loop` | Body + condition + end/step (supports `break`/`continue`) | -| `lambda` | Inline function value | -| `template` | Handlebars-powered string interpolation | -| `flow` | `return` / `break` / `continue` / `throw` signals | -| `native` | Direct call into a registered native implementation | - -### `Registry` - -Central authority: - -1. Maps `name → Type class` for JSON parse dispatch. -2. Maps `name → Type instance` for user-registered named types. -3. Maps `id → NativeImpl` for native-function overrides. -4. Implements `TypeBuilder` — the factory for constructing types - (`r.num()`, `r.list(r.text())`, `r.fn(args, returns)`, ...). +`{ kind: 'new', type: , value?: }` -```ts -const r = createRegistry(); -r.register(r.extend(r.num(), { name: 'Positive', constraint: /* ... */ })); -r.setNative('my.op', (scope, registry) => val(registry.text(), 'ok')); -``` +If the type has `init`, `value` is parsed as `init.args` and the +constructor runs. Otherwise `value` is parsed as `type` directly. With +no `value`, returns `Value(type, type.create())` — the type's default. -### `Engine` +### `get` — read through a path -Stateless across runs. Each `run()` builds a fresh root scope seeded -with registered globals plus per-call extras: +`{ kind: 'get', path: [, , ...] }` -```ts -const engine = createEngine(r); -engine.registerGlobal('PI', { type: r.num(), value: 3.14 }); -const result = await engine.run(expr, { userInput: val(r.text(), 'hello') }); -``` +Steps walk left-to-right. Each step is `{prop: 'name'}` (named +access), `{args: {...}}` (call the previous step — used after a +method or any callable), or `{key: }` (indexed access). The +first step is always `{prop: ''}`. Result is the final +step's value. -Also exposes `engine.typeOf(expr)` (static type inference) and -`engine.validate(expr)` (structural problem collection) for tooling -that wants to analyze a program without running it. +### `set` — write through a path -## Type system +`{ kind: 'set', path: [, ...], value: }` -### Leaves +Same path grammar as `get`, but the tail step writes. Returns `bool`: +true on success, false if a safe-nav null/undefined short-circuited +the walk. -| Type | Options | -|---|---| -| `any` | top type — accepts anything | -| `void` / `null` | bottom-ish unit types | -| `bool` | `{}` | -| `num` | `min`, `max`, `whole`, `minPrecision`, `maxPrecision`, `prefix`, `suffix` | -| `text` | `minLength`, `maxLength`, `pattern`, `flags` | -| `date` / `timestamp` | `min`, `max`, `utc`, (timestamp) `precision` | -| `duration` | milliseconds | -| `color` | `hasAlpha` | -| `literal` | exact-value constraint over inner type | - -All leaves enforce their options at `parse()` time and carry them -through to `toValueSchema()`. - -### Containers - -| Type | Description | -|---|---| -| `list` | ordered collection; `minLength`/`maxLength` | -| `map` | typed entry list — LLM-friendly shape `[{key,value}]` | -| `tuple` | fixed-arity positional | -| `obj{prop: Type, ...}` | structural record with declared fields | -| `optional` | `T ∣ undefined` | -| `nullable` | `T ∣ null` | -| `fn` | callable with obj args, return R, optional throws E | -| `iface{props, get, call}` | contract a value must satisfy structurally | -| `enum` | constrained set of values | -| `or` / `and` / `not` | type algebra | -| `ref` | lazy reference to a registered named type (enables recursion) | -| `generic` | type-parameter placeholder | -| `typ` | values ARE Types; T constrains which Types are acceptable | - -### Extensions +### `define` — bind locals into a child scope -```ts -const Task = r.extend(r.obj({ - title: { type: r.text({ minLength: 1 }) }, - done: { type: r.bool() }, -}), { - name: 'Task', - docs: 'An action item in a to-do list', - props: { - isOverdue: r.method({}, r.bool(), 'task.isOverdue'), - }, -}); -r.register(Task); -``` +`{ kind: 'define', vars: [{ name, type?, value }, ...], body: }` -An `Extension` wraps a base type, adds local options / fields / methods -/ constraint predicates, and preserves structural compatibility with -the base. Multi-level extension is supported — every layer's props -compose. +Each var is added to scope BEFORE the next var's value is evaluated, +so later vars can reference earlier ones. The body runs with all +vars in scope; its result is the define's value. -### Recursive types & generics — both ride the same `AliasType` +### `block` — sequence of expressions -Bare-name TypeDefs (`{name: 'X'}` with no other peers) parse as -`AliasType('X')`. That single class covers what used to be two -distinct concepts: +`{ kind: 'block', lines: [, ...] }` -- **Lazy reference** to a named type registered with the registry — - the target doesn't need to exist at construction time, so mutual - cycles work: +Lines run in order. Earlier lines are evaluated for their side +effects (set, native calls, fns); the block's value is the LAST +line's value. An empty block returns void. - ```ts - const Task = r.extend(r.obj({ - title: { type: r.text() }, - creator: { type: r.alias('User') }, - }), { name: 'Task' }); - r.register(Task); +### `if` — conditional branching - const User = r.extend(r.obj({ - name: { type: r.text() }, - tasks: { type: r.list(r.alias('Task')) }, - }), { name: 'User' }); - r.register(User); - ``` +`{ kind: 'if', ifs: [{ condition, body }, ...], else?: }` -- **Generic placeholder** — the same builder. Type-parameterized - types (`list`, `map`, `typ`) store their parameters in a - `generic: Record` map; the placeholders inside are - AliasTypes captured against the enclosing local scope. +Each condition must be `bool`-typed. First branch whose condition is +true wins. Without an else, a no-match if-expression returns void. -`AliasType.resolve(extra?)` walks an optional caller-supplied -TypeScope first, then its captured scope. That's the only resolution -mechanism — no `bind()` / `substitute()` / type-tree rebuilding. -Pre-resolution, an unresolved alias acts as a maximally permissive -placeholder. +### `switch` — value-based branching -Function types support **method-level generics**: +`{ kind: 'switch', value: , cases: [{ equals: [...], body }], else?: }` -```ts -// list.map(fn: (value:V, index:num) => R): list -const listT = r.list(r.alias('V')); -listT.toCodeDefinition(); -// type list { -// map(fn: (value: V, index: num): R): list -// ... -// } -``` +The case wins if `value` equals ANY one of `equals`. Cases are NOT +fall-through; only the matching case's body runs. -#### Specializing generics at call sites — `TypeScope` +### `loop` — iterate any iterable -Resolution-touching methods on `Type` (`parse`, `valid`, `compatible`, -`props`, `prop`, `get`, `call`, `init`, `follow`, `like`, `simplify`) -take an optional `scope?: TypeScope`. Pass a `LocalScope` of bindings -to override `R` (etc.) without rebuilding anything: +`{ kind: 'loop', over: , body: , key?: string, value?: string, parallel?: {...} }` -```ts -import { LocalScope } from '@aeye/gin'; - -// fn map(fn: (value: V, index: num) => R): list -const mapFn = r.fn( - r.obj({ fn: { type: r.fn(r.obj({ value: { type: V } /*…*/ }), r.alias('R')) } }), - r.list(r.alias('R')), - undefined, - { R: r.any() }, -); +Two evaluation modes by `over`'s static type: +- **Iterable** (`get().loop` defined): walked once. `key` / `value` + bind to scope under those names (override defaults via the optional + fields). +- **Bool while-loop** (`get().loopDynamic === true`): `over` is + RE-EVALUATED each iteration. The loop continues while truthy and + exits the moment it becomes false. `bool` uses this. -const local = new LocalScope(r, { R: r.num() }); -mapFn.call(local).returns!.simplify(local).name === 'list'; // list -``` +Optional `parallel: { concurrent?, rate? }` fans body execution out: +`concurrent` caps simultaneous bodies, `rate` paces start times. The +native iterator just calls `yield(k, v)`; the parallel orchestration +sits in `LoopExpr.evaluate` so every iterable inherits it for free. -Path-step `generic` bindings (`[..., {args, generic: {R: numDef}}]`) -work the same way: `CallStep.callSiteScope(calledType)` builds the -`LocalScope` once per call and threads it into the type's resolution -methods. The fn type itself is never cloned. +### `lambda` — callable closure over the lexical scope -### `typ` — types-as-values +`{ kind: 'lambda', type: , body: , constraint?: }` -Sometimes you want a program to receive a *type* as an argument — e.g. -"parse this HTTP response as `T`". `typ` does that: +Inside the body, `args` is the call-site arguments obj and `recurse` +is this lambda (for self-calls). Optional `constraint` runs before +the body each call (must return `bool`); throws on false. -```ts -// fn fetch(args: { url: text, output?: typ }): R -const fetchFn = r.fn( - r.obj({ - url: { type: r.text() }, - output: { type: r.optional(r.typ(r.alias('R'))) }, - }), - r.alias('R'), - undefined, - { R: r.text() }, -); -``` +### `template` — string interpolation + +`{ kind: 'template', template: '', params: }` + +Each `{name}` placeholder in the string is replaced with the +stringified `params.name`. Compiles to a JS template literal in +`toCode` rendering when params is a `new obj` literal. + +### `flow` — non-local control flow + +`{ kind: 'flow', action: 'break' | 'continue' | 'return' | 'exit' | 'throw', value?, error? }` + +- `break` / `continue` — only valid inside a `loop`. +- `return` — unwinds to the enclosing lambda or fn body; `value` + becomes the result. +- `exit` — unwinds all the way to `engine.run`; `value` becomes the + program result. +- `throw` — raises `error`; caught by a path step's `catch:` handler. + +### `native` — escape hatch to a registered native impl + +`{ kind: 'native', id: '', type?: }` + +Calls into a JS/TS function registered via `registry.setNative(id, +impl)`. Most natives are referenced indirectly — `num.add`'s prop +type carries `{kind: 'native', id: 'num.add'}` as its get expression, +so a path call to `.add` dispatches without any explicit `native` +node in user code. You'd hand-write a `native` node when authoring a +custom loop ExprDef or a method whose impl lives outside gin. + +--- + +## Parsing + +gin has TWO levels of parsing — they compose: + +1. **JSON → runtime objects.** `registry.parse(typeDef)` turns a + `TypeDef` JSON into a `Type` instance; `registry.parseExpr(exprDef, + scope?)` turns an `ExprDef` into an `Expr`. Inverse: + `type.toJSON()` / `expr.toJSON()`. Round-trips losslessly. -`typ`'s runtime `.raw` is a `Type` instance (one-shot parsed from -TypeDef JSON). Its `toValueSchema()` emits a Zod union of every -registry type compatible with `num` — `{name:'num'}`, `{name:'Positive'}` -(if registered), etc. — plus an inline-Extension branch whose `extends` -enum is narrowed to compatible bases. The LLM sees exactly the valid -choices. +2. **Runtime data → typed values.** Once you have a `Type`, calling + `type.parse(jsonData)` validates the data and returns a `Value` + — the runtime currency. A `Value` is a `{type, raw}` pair where + `raw` is the JS storage shape. `value.toJSON()` produces the JSON + shape; `type.encode(value.raw)` does the same at the type level. -## Schema layers +Both levels are scope-aware. Generic placeholders (`AliasType`) +resolve through the scope passed to parse — that's how a `CallStep`'s +`generic: { R: }` map flows into the called signature without +rebuilding the type tree. -gin produces four distinct Zod schemas, each for a different purpose: +--- -| Method | What it validates | +## The Registry — the only class you really need + +`Registry` is your interface. Every other class (`Type`, `Expr`, +`Engine`, `Value`, `Path`, ...) is reachable through it. You'll rarely +construct one yourself — `createRegistry()` ships with every built-in +type, native, and Expr class pre-registered. + +Key methods: + +| Method | Purpose | |---|---| -| `static TypeClass.toSchema(opts)` | The TypeDef JSON shape for this class. Used by `buildSchemas` to union every registered type. | -| `type.toValueSchema(opts)` | A runtime value of this type (a number for `num`, `{x,y}` for an obj). Feeds LLM structured-output modes. | -| `type.toNewSchema(opts)` | The `value:` side of a `{kind:'new'}` expression. For composites, each slot is `opts.Expr` (any expression). | -| `type.toInstanceSchema()` | Narrow-match against this specific instance's TypeDef JSON. Used by `typ` to emit the compatible-types union. | +| `parse(def)` / `parseExpr(def, scope?)` | TypeDef / ExprDef → runtime | +| `define(cls)` | Register a built-in Type class for JSON dispatch | +| `register(type)` | Register a named Type instance (typically an Extension) | +| `lookup(name)` | Look up a Type by name (registered → built-in fallback) | +| `setNative(id, impl)` | Wire a JS function as a gin native | +| `getNative(id)` | Read it back | +| `defineExpr(cls)` | Register an ExprClass (12 ship; you rarely add more) | +| `extend(base, { name, ... })` | Create a named Extension | +| `augment(name, { props?, get?, call?, init? })` | Add to an existing type by name | +| `augmentation(name)` | Read augmentation back | +| `like(type)` | Pick a registered concrete type compatible with a constraint | + +The builder methods (`r.num()`, `r.text()`, `r.list(item)`, `r.obj({...})`, +`r.fn(args, returns, throws?, generic?)`, `r.iface({...})`, +`r.method(args, returns, nativeId)`, `r.prop(type, nativeId)`, ...) are +sugar for parse — they construct runtime types without going through +JSON. + +`createEngine(registry)` builds an Engine that owns evaluation, +validation, and type-inference walks. Programs run via `engine.run(expr, +extras?)`; static analysis via `engine.validate(expr)` / +`engine.typeOf(expr)`. + +--- + +## Built-in type catalog + +Below is what `createRegistry()` ships with — the surface every gin +program starts with. Each type's section is the same `toCodeDefinition` +output an LLM sees in its prompt. -`buildSchemas(registry, overrides?)` composes the recursive -`opts.Type` / `opts.Expr` schemas the LLM uses to author programs. Pass -`{ newStrict: true }` to get a discriminated union per registered type -instead of the loose class-level fallback. +``` +type any { + toAny(): any + typeOf(): text + is(): bool + as(): optional + toText(): text + toBool(): bool + eq(other: any): bool + neq(other: any): bool +} + +type void { + toAny(): any + toText(): text + toBool(): bool +} -## Native functions +type null { + toAny(): any + toText(): text + toBool(): bool +} + +type bool { + [key: num{whole=true, min=0}]: bool + toAny(): any + eq(other: bool): bool + neq(other: bool): bool + and(other: bool): bool + or(other: bool): bool + xor(other: bool): bool + not(): bool + toText(): text + toNum(): num +} + +type num { + [key: num{whole=true, min=0}]: num + toAny(): any + eq(other: num, epsilon?: num): bool + neq(other: num, epsilon?: num): bool + lt(other: num): bool + lte(other: num): bool + gt(other: num): bool + gte(other: num): bool + add(other: num): num + sub(other: num): num + mul(other: num): num + div(other: num): num + mod(other: num): num + pow(other: num): num + abs(): num + neg(): num + sign(): num + sqrt(): num + min(other: num): num + max(other: num): num + clamp(min: num, max: num): num + floor(): num + ceil(): num + round(): num + isZero(): bool + isPositive(): bool + isNegative(): bool + isInteger(): bool + isEven(): bool + isOdd(): bool + toText(precision?: num): text + toBool(): bool +} + +type text { + [key: num]: text{minLength=1, maxLength=1} + toAny(): any + length: num + eq(other: text): bool + neq(other: text): bool + contains(search: text): bool + startsWith(prefix: text): bool + endsWith(suffix: text): bool + trim(): text + trimStart(): text + trimEnd(): text + upper(): text + lower(): text + slice(start: num, end?: num): text + replace(search: text, replacement: text): text + split(separator: text): list + concat(other: text): text + repeat(count: num): text + indexOf(search: text, from?: num): num + lastIndexOf(search: text, from?: num): num + match(pattern: text): list + test(pattern: text): bool + isEmpty(): bool + isNotEmpty(): bool + toNum(): num + toBool(): bool +} + +type list { + [key: num{whole=true, min=0}]: V + length: num + at(index: num): optional + push(value: V): void + pop(): optional + shift(): optional + unshift(value: V): void + insert(index: num, value: V): void + remove(index: num): V + clear(): void + slice(start?: num, end?: num): list + concat(other: list): list + reverse(): list + join(separator?: text): text + indexOf(value: V): num + contains(value: V): bool + unique(): list + duplicates(): list + map(fn: (value: V, index: num): R): list + filter(fn: (value: V, index: num): bool): list + find(fn: (value: V, index: num): bool): optional + reduce(fn: (acc: R, value: V, index: num): R, initial: R): R + some(fn: (value: V, index: num): bool): bool + every(fn: (value: V, index: num): bool): bool + sort(fn?: (a: V, b: V): num): list + isEmpty(): bool + isNotEmpty(): bool + first?: V + last?: V +} + +type map { + [key: K]: V + size: num + at(key: K): optional + has(key: K): bool + delete(key: K): bool + clear(): void + keys(): list + values(): list + isEmpty(): bool + isNotEmpty(): bool +} + +type tuple<...elements> { + [key: num]: + length: num + first: + last: + toList(): list +} + +type obj { + keys(): list + values(): list + entries(): list> + has(key: text): bool + eq(other: any): bool + neq(other: any): bool + toText(): text +} + +type optional { + value: T + has(): bool + or(fallback: T): T + map(fn: (value: T): R): optional +} + +type nullable { + value: T + isNull(): bool + or(fallback: T): T + map(fn: (value: T): R): nullable +} + +type or<...variants> // union; props/get/call when ALL variants share them +type and<...parts> // intersection; props from ANY part +type not // any value EXCEPT one matching excluded +type literal // one specific constant value of T +type enum // named constants of value type V +type function // see "call" — args/returns/throws/generic +type interface // structural contract; props/get/call only +type typ // a value that IS a Type, constrained by T +type alias // bare-name reference / generic placeholder + +type date { + year, month, day, dayOfWeek, dayOfYear // num + eq, neq, before, after // (other: date) → bool + addDays/Months/Years, diffDays/Months/Years + toText(format?): text +} + +type timestamp { + year..millisecond // num + eq, before, after // (other: timestamp) → bool + addDuration, subDuration, diff + toDate(): date + toEpoch(): num + toText(format?): text +} + +type duration { + new(days?, hours?, minutes?, seconds?, ms?) + totalSeconds, totalMinutes, totalHours, totalDays + days, hours, minutes, seconds, ms + toText(format?): text +} + +type color { + new(r, g, b, a?) + r, g, b, a, hue, saturation, lightness // num + eq, neq // (other: color) → bool + lighten, darken, saturate, desaturate, opacity, invert, mix, complement + toHex, toRgb, toHsl, toText // → text +} +``` + +--- + +## Putting it together + +A single example demonstrating the four developer-facing surfaces: ```ts -r.setNative('num.add', (scope, registry) => { - const self = scope.get('this')!.raw as number; - const other = (scope.get('args')!.raw as any).other.raw as number; - return val(registry.num(), self + other); +import { createRegistry, createEngine, GetSet, Init, val, Value } from '@aeye/gin'; + +const r = createRegistry(); + +// 1. Extension — a real subtype with its own surface. +const Email = r.extend( + r.text({ pattern: '^[^@]+@[^@]+$', minLength: 3 }), + { + name: 'Email', + docs: 'A text value matching a basic email shape', + props: { + domain: r.method({}, r.text(), 'Email.domain'), + }, + }, +); +r.register(Email); + +// 2. Native — the JS implementation of Email.domain. Natives access +// `this` via scope.get('this'). +r.setNative('Email.domain', (scope, reg) => { + const self = scope.get('this')!.raw as string; + return val(reg.text(), self.split('@')[1] ?? ''); }); -``` -Built-in natives for every leaf/container method are registered by -`registerBuiltinNatives(registry)`. User code can override any of them -by id to inject instrumentation or swap implementations. +// 3. Augmentation — give the existing `num` type a `clamp01` method, +// AND a constructor so `new num({percent})` produces a 0–1 num. +r.augment('num', { + props: { + clamp01: r.method({}, r.num({ min: 0, max: 1 }), 'num.clamp01'), + }, + init: new Init({ + args: r.obj({ percent: { type: r.num({ min: 0, max: 100 }) } }) as any, + run: { kind: 'native', id: 'num.fromPercent' }, + }), +}); -## Analysis without running +r.setNative('num.clamp01', (scope, reg) => { + const n = scope.get('this')!.raw as number; + return val(reg.num({ min: 0, max: 1 }), Math.max(0, Math.min(1, n))); +}); +r.setNative('num.fromPercent', (scope, reg) => { + const args = scope.get('args')!.raw as Record; + const pct = args['percent']!.raw as number; + return val(reg.num({ min: 0, max: 1 }), pct / 100); +}); -- `engine.typeOf(expr)` returns the inferred `Type` of an expression - under a given `TypeScope`. Never throws — unknowns fall through to `any`. -- `engine.validate(expr)` walks the AST collecting `Problems` — unknown - vars, unknown natives, out-of-place `break`/`return`, etc. Useful - for warning the LLM before wasting a full run. +// 4. Run a program. (Programs are JSON — typically authored by an LLM, +// not hand-written. Here we hand-write one for illustration.) +const engine = createEngine(r); -## Testing +const program = { + kind: 'block', + lines: [ + { + kind: 'define', + vars: [ + // `new num({percent: 75})` — augmented init runs; result is 0.75. + { name: 'opacity', value: { + kind: 'new', + type: { name: 'num' }, + value: { percent: 75 }, + } }, + { name: 'address', value: { + kind: 'new', + type: { name: 'Email' }, + value: 'team@example.com', + } }, + ], + body: { + kind: 'block', + lines: [ + // Augmented method: opacity.clamp01() — already in [0,1]. + { kind: 'get', path: [{ prop: 'opacity' }, { prop: 'clamp01' }, { args: {} }] }, + // Extension method: address.domain() → 'example.com'. + { kind: 'get', path: [{ prop: 'address' }, { prop: 'domain' }, { args: {} }] }, + ], + }, + }, + ], +}; -```bash -npm test # 615+ tests covering every type, expression, and edge -npm run dump-schema # emit a sample opts.Type/opts.Expr union -npm run dump-code # emit toCodeDefinition() for every built-in +const result = await engine.run(program); +console.log(result.raw); // 'example.com' ``` -## Use cases - -- **Typed tool outputs for LLM agents.** Have the LLM produce an - ExprDef the agent can statically validate, execute, and trust the - return shape of. -- **Runtime-authored programs.** Let users (or models) define - pipelines, transformations, or DSLs without shipping a parser. -- **Structured-output schema generation.** Produce Zod schemas from - typed user-input definitions and pass them straight to - `ai.chat.get({responseFormat})`-style APIs. -- **Cross-session persistence.** Every Type and Expr is JSON — write - it to disk, load it later, execute against the same registry. -- **Sandboxed execution.** Programs only see what you registered as - natives and globals; no filesystem, no network unless you wire it. - -## Related packages - -- **[`@aeye/ginny`](../ginny)** — a CLI that turns natural-language - requests into executable gin programs. Uses the type system and - expression engine described here as its runtime. +What this exercises: + +- `r.extend(...)` produces `Email`, a real subtype of `text` with a + custom prop. Static analysis treats Email as text everywhere text + is expected. +- `r.augment('num', ...)` adds `clamp01` AND `init` to the canonical + `num` type. Every num — including extensions over num — picks them + up. `new num({percent: 75})` flows through the augmented init. +- `r.setNative(id, impl)` wires the JS implementations. Any path call + that references those native ids dispatches through them. +- `engine.run(program)` evaluates the JSON tree, validating types as + it walks. + +Augmentations and extensions live on the registry. Pass that registry +to the engine — and to any prompt schema generator (`buildSchemas(r)`) +— so the LLM authoring programs sees the full surface. + +--- ## License From f72547766eb758b08355ec2ca5146dd3a8e2c925 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Fri, 1 May 2026 06:28:51 -0400 Subject: [PATCH 11/21] function => fn & loop dynamic & parallel support --- packages/gin/README.md | 6 + .../src/__tests__/call-type-aliases.test.ts | 16 +-- .../src/__tests__/composite-values.test.ts | 2 +- .../gin/src/__tests__/constraints.test.ts | 2 +- packages/gin/src/__tests__/deep-set.test.ts | 8 +- .../gin/src/__tests__/expr-validate.test.ts | 4 +- .../__tests__/exprs-lambda-template.test.ts | 4 +- .../src/__tests__/extension-generics.test.ts | 2 +- packages/gin/src/__tests__/extension.test.ts | 2 +- packages/gin/src/__tests__/fn.test.ts | 2 +- .../gin/src/__tests__/gaps-analysis.test.ts | 4 +- .../gin/src/__tests__/gaps-satisfies.test.ts | 4 +- packages/gin/src/__tests__/iface.test.ts | 4 +- packages/gin/src/__tests__/list.test.ts | 2 +- .../gin/src/__tests__/loop-while-bool.test.ts | 74 ++++++++++- packages/gin/src/__tests__/map.test.ts | 2 +- .../src/__tests__/natives-collections.test.ts | 2 +- packages/gin/src/__tests__/readme.test.ts | 2 +- packages/gin/src/__tests__/recurse.test.ts | 8 +- packages/gin/src/__tests__/registry.test.ts | 2 +- .../gin/src/__tests__/scopes-typedef.test.ts | 4 +- .../gin/src/__tests__/super-override.test.ts | 4 +- packages/gin/src/__tests__/toCode.test.ts | 6 +- packages/gin/src/__tests__/toSchema.test.ts | 2 +- .../gin/src/__tests__/validate-set.test.ts | 2 +- packages/gin/src/exprs/lambda.ts | 2 +- packages/gin/src/exprs/loop.ts | 122 ++++++++++++++---- packages/gin/src/types/fn.ts | 4 +- packages/ginny/src/prompts/designer.ts | 4 +- packages/ginny/src/prompts/programmer.ts | 15 ++- packages/ginny/src/tools/finish.ts | 2 +- 31 files changed, 234 insertions(+), 85 deletions(-) diff --git a/packages/gin/README.md b/packages/gin/README.md index b3ee8fe..e46d4a4 100644 --- a/packages/gin/README.md +++ b/packages/gin/README.md @@ -253,6 +253,12 @@ Optional `parallel: { concurrent?, rate? }` fans body execution out: native iterator just calls `yield(k, v)`; the parallel orchestration sits in `LoopExpr.evaluate` so every iterable inherits it for free. +Parallel composes with the dynamic mode too: `bool over` plus +`parallel: { concurrent: 3 }` fans the body out up to 3 in-flight, +and `over` is re-evaluated against the outer scope every time a task +COMPLETES (not when it starts). So accumulating side effects from +the prior batch decide whether more tasks spawn. + ### `lambda` — callable closure over the lexical scope `{ kind: 'lambda', type: , body: , constraint?: }` diff --git a/packages/gin/src/__tests__/call-type-aliases.test.ts b/packages/gin/src/__tests__/call-type-aliases.test.ts index cd4f9b3..6064046 100644 --- a/packages/gin/src/__tests__/call-type-aliases.test.ts +++ b/packages/gin/src/__tests__/call-type-aliases.test.ts @@ -19,7 +19,7 @@ const e = new Engine(r); describe('CallDef.types — basic resolution', () => { test('alias referenced twice in args resolves to the alias target', () => { const fn = r.parse({ - name: 'function', + name: 'fn', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, args: { name: 'obj', props: { a: { type: { name: 'counter' } }, b: { type: { name: 'counter' } } } }, @@ -40,7 +40,7 @@ describe('CallDef.types — basic resolution', () => { test('sequential aliases — later refs earlier', () => { const fn = r.parse({ - name: 'function', + name: 'fn', call: { types: { A: { name: 'num', options: { whole: true, min: 1 } }, @@ -63,7 +63,7 @@ describe('CallDef.types — basic resolution', () => { test('alias references generic — extra-scope T=text resolves through the alias', () => { const fn = r.parse({ - name: 'function', + name: 'fn', generic: { T: { name: 'T' } }, call: { types: { @@ -88,7 +88,7 @@ describe('CallDef.types — basic resolution', () => { describe('CallDef.types — round-trip', () => { test('toJSON preserves the source `types` map and alias references', () => { const def: TypeDef = { - name: 'function', + name: 'fn', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, args: { name: 'obj', props: { a: { type: { name: 'counter' } } } }, @@ -106,7 +106,7 @@ describe('CallDef.types — round-trip', () => { test('parse → toJSON → parse produces structurally identical args', () => { const def: TypeDef = { - name: 'function', + name: 'fn', call: { types: { A: { name: 'num', options: { min: 0 } }, @@ -127,7 +127,7 @@ describe('CallDef.types — round-trip', () => { // regardless of which scopes consult it. toJSON always emits the // declared shape — `T` survives bare, `box` survives. const fn = r.parse({ - name: 'function', + name: 'fn', generic: { T: { name: 'T' } }, call: { types: { box: { name: 'list', generic: { V: { name: 'T' } } } }, @@ -149,7 +149,7 @@ describe('CallDef.types — ExprDef bodies', () => { test('alias referenced inside `call.get` body resolves correctly', async () => { // counterFn() => 7 (where `counter` aliases num{min:1, whole:true}) const fnType = r.parse({ - name: 'function', + name: 'fn', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, args: { name: 'obj' }, @@ -169,7 +169,7 @@ describe('CallDef.types — ExprDef bodies', () => { describe('CallDef.types — toCodeDefinition rendering', () => { test('aliases render as `type X = …;` lines before the call signature', () => { const fn = r.parse({ - name: 'function', + name: 'fn', call: { types: { counter: { name: 'num', options: { whole: true, min: 1 } } }, args: { name: 'obj', props: { n: { type: { name: 'counter' } } } }, diff --git a/packages/gin/src/__tests__/composite-values.test.ts b/packages/gin/src/__tests__/composite-values.test.ts index 661d19f..a1c76fd 100644 --- a/packages/gin/src/__tests__/composite-values.test.ts +++ b/packages/gin/src/__tests__/composite-values.test.ts @@ -34,7 +34,7 @@ describe('composite values preserve actual element types', () => { test('obj with interface field retains the concrete type of the stored value', () => { const r = createRegistry(); const comparable = r.iface({ - props: { toText: { type: { name: 'function', call: { + props: { toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' }, } } } }, }); diff --git a/packages/gin/src/__tests__/constraints.test.ts b/packages/gin/src/__tests__/constraints.test.ts index 95e077c..52f82ee 100644 --- a/packages/gin/src/__tests__/constraints.test.ts +++ b/packages/gin/src/__tests__/constraints.test.ts @@ -119,7 +119,7 @@ describe('Lambda constraints', () => { const lambda = r.parseExpr({ kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' }, diff --git a/packages/gin/src/__tests__/deep-set.test.ts b/packages/gin/src/__tests__/deep-set.test.ts index e206be3..6378955 100644 --- a/packages/gin/src/__tests__/deep-set.test.ts +++ b/packages/gin/src/__tests__/deep-set.test.ts @@ -108,7 +108,7 @@ describe('set return value + safe-navigation', () => { test('safe-nav does NOT short-circuit a call step (Fn raw may be null)', async () => { const r = createRegistry(); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, @@ -270,7 +270,7 @@ describe('deep set: method call with CallDef.set', () => { const r = createRegistry(); // Method whose Fn type has call.set — set body pushes {args.key, value} to log. const setterFn = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { key: { type: { name: 'text' } } } }, returns: { name: 'num' }, @@ -337,7 +337,7 @@ describe('deep set: direct call with CallDef.set', () => { test('`fn(args) = value` invokes the Fn\'s call.set', async () => { const r = createRegistry(); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, @@ -381,7 +381,7 @@ describe('deep set: direct call with CallDef.set', () => { name: 'fn', value: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }], diff --git a/packages/gin/src/__tests__/expr-validate.test.ts b/packages/gin/src/__tests__/expr-validate.test.ts index d36d0c2..6619f18 100644 --- a/packages/gin/src/__tests__/expr-validate.test.ts +++ b/packages/gin/src/__tests__/expr-validate.test.ts @@ -62,7 +62,7 @@ describe('LambdaExpr validation', () => { test('body type incompatible with declared returns → warn', () => { const probs = e.validate({ kind: 'lambda', - type: { name: 'function', call: { + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' }, } }, @@ -74,7 +74,7 @@ describe('LambdaExpr validation', () => { test('body type matches declared returns → no warn', () => { const probs = e.validate({ kind: 'lambda', - type: { name: 'function', call: { + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' }, } }, diff --git a/packages/gin/src/__tests__/exprs-lambda-template.test.ts b/packages/gin/src/__tests__/exprs-lambda-template.test.ts index c1df82b..d8344d0 100644 --- a/packages/gin/src/__tests__/exprs-lambda-template.test.ts +++ b/packages/gin/src/__tests__/exprs-lambda-template.test.ts @@ -23,7 +23,7 @@ describe('evalLambda + list.map', () => { fn: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { value: { type: { name: 'num' } }, index: { type: { name: 'num' } } } }, returns: { name: 'num' }, @@ -65,7 +65,7 @@ describe('evalLambda + list.map', () => { args: { fn: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, body: { kind: 'get', path: [ diff --git a/packages/gin/src/__tests__/extension-generics.test.ts b/packages/gin/src/__tests__/extension-generics.test.ts index 5839ae4..899c489 100644 --- a/packages/gin/src/__tests__/extension-generics.test.ts +++ b/packages/gin/src/__tests__/extension-generics.test.ts @@ -70,7 +70,7 @@ describe('Extension generics', () => { test('generic on call: identity(x: T): T resolves via extra-scope', () => { const reg = createRegistry(); const T = reg.alias('T'); - const Fn = reg.extend('function', { + const Fn = reg.extend('fn', { name: 'identity', generic: { T }, call: { args: reg.obj({ x: { type: T } }), returns: T }, diff --git a/packages/gin/src/__tests__/extension.test.ts b/packages/gin/src/__tests__/extension.test.ts index 0762a1d..6f8cf4b 100644 --- a/packages/gin/src/__tests__/extension.test.ts +++ b/packages/gin/src/__tests__/extension.test.ts @@ -64,7 +64,7 @@ describe('Extension', () => { test('auto-Extension: fn.call is native (no wrap)', () => { const r = createRegistry(); const json = { - name: 'function', + name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } }, }; const back = r.parse(json); diff --git a/packages/gin/src/__tests__/fn.test.ts b/packages/gin/src/__tests__/fn.test.ts index 6a76274..4c986d6 100644 --- a/packages/gin/src/__tests__/fn.test.ts +++ b/packages/gin/src/__tests__/fn.test.ts @@ -41,7 +41,7 @@ describe('FnType', () => { test('call is natively consumed → no auto-Extension', () => { const json = { - name: 'function', + name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } }, }; const back = r.parse(json); diff --git a/packages/gin/src/__tests__/gaps-analysis.test.ts b/packages/gin/src/__tests__/gaps-analysis.test.ts index 09de6df..af21cb2 100644 --- a/packages/gin/src/__tests__/gaps-analysis.test.ts +++ b/packages/gin/src/__tests__/gaps-analysis.test.ts @@ -12,10 +12,10 @@ describe('Engine.typeOf', () => { test('lambda returns the declared fn type', () => { const t = e.typeOf({ kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } }, body: { kind: 'new', type: { name: 'text' }, value: 'hi' }, } as any); - expect(t.name).toBe('function'); + expect(t.name).toBe('fn'); }); test('block returns last line type', () => { diff --git a/packages/gin/src/__tests__/gaps-satisfies.test.ts b/packages/gin/src/__tests__/gaps-satisfies.test.ts index 66b3796..3615c2d 100644 --- a/packages/gin/src/__tests__/gaps-satisfies.test.ts +++ b/packages/gin/src/__tests__/gaps-satisfies.test.ts @@ -24,7 +24,7 @@ describe('satisfies enforcement', () => { // Any interface whose requirements num already meets (e.g., has eq). const iface = r.iface({ props: { - eq: { type: { name: 'function', call: { args: { name: 'obj', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, + eq: { type: { name: 'fn', call: { args: { name: 'obj', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, }, }); const named = r.extend(iface, { name: 'has-eq' }); @@ -39,7 +39,7 @@ describe('Registry.getTypesFor', () => { // Build an interface requiring a `toText` method. const iface = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); const named = r.extend(iface, { name: 'has-toText' }); diff --git a/packages/gin/src/__tests__/iface.test.ts b/packages/gin/src/__tests__/iface.test.ts index db090d1..aedb54e 100644 --- a/packages/gin/src/__tests__/iface.test.ts +++ b/packages/gin/src/__tests__/iface.test.ts @@ -8,7 +8,7 @@ describe('IfaceType', () => { test('builder accepts a spec', () => { const i = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); expect(i).toBeInstanceOf(IfaceType); @@ -17,7 +17,7 @@ describe('IfaceType', () => { test('compatible: type that has matching props satisfies interface', () => { const i = r.iface({ props: { - toText: { type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, + toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); // num has toText — should satisfy diff --git a/packages/gin/src/__tests__/list.test.ts b/packages/gin/src/__tests__/list.test.ts index 9840194..1ed0e70 100644 --- a/packages/gin/src/__tests__/list.test.ts +++ b/packages/gin/src/__tests__/list.test.ts @@ -80,7 +80,7 @@ describe('ListType', () => { test('at method returns optional V', () => { const p = r.list(r.num()).props(); - expect(p.at?.type.name).toBe('function'); + expect(p.at?.type.name).toBe('fn'); }); test('encode + parse roundtrip', () => { diff --git a/packages/gin/src/__tests__/loop-while-bool.test.ts b/packages/gin/src/__tests__/loop-while-bool.test.ts index 96e7be3..95ef9a8 100644 --- a/packages/gin/src/__tests__/loop-while-bool.test.ts +++ b/packages/gin/src/__tests__/loop-while-bool.test.ts @@ -276,14 +276,84 @@ describe('LoopExpr — validation accepts bool over', () => { expect(probs.list.some((p) => p.code === 'loop.not-iterable')).toBe(false); }); - test('parallel options on a dynamic (bool) loop flag loop.parallel.dynamic', () => { + test('parallel options on a dynamic (bool) loop are accepted', () => { + // Dynamic + parallel runs the body concurrently up to `concurrent` + // tasks; `over` is re-evaluated after each completion. No analyzer + // warning — both modes compose. const probs = e.validate({ kind: 'loop', over: boolLit(true), parallel: { concurrent: numLit(2) }, body: { kind: 'flow', action: 'break' }, }); - expect(probs.list.some((p) => p.code === 'loop.parallel.dynamic')).toBe(true); + expect(probs.list.some((p) => p.code === 'loop.parallel.dynamic')).toBe(false); + }); + + test('dynamic + parallel: body runs concurrently up to `concurrent`', async () => { + // `over` flips false once the counter reaches 6. With concurrent=3, + // the test.busy probe should report 3 simultaneously in-flight at + // peak. Wall time should be roughly 2 batches × 50ms (≤ ~150ms), + // not 6 × 50ms = 300ms. + let inFlight = 0; + let max = 0; + const r2 = createRegistry(); + const e2 = new Engine(r2); + r2.setNative('test.busy', async (_scope, reg) => { + inFlight++; + if (inFlight > max) max = inFlight; + await new Promise((res) => setTimeout(res, 50)); + inFlight--; + return val(reg.void(), undefined); + }); + + const program = { + kind: 'define', + vars: [ + { name: 'count', value: { kind: 'new', type: { name: 'num' }, value: 0 } }, + ], + body: { + kind: 'loop', + // over = count.lt(6); re-evaluated after each task completes. + over: { + kind: 'get', + path: [ + { prop: 'count' }, { prop: 'lt' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 6 } } }, + ], + }, + parallel: { concurrent: { kind: 'new', type: { name: 'num' }, value: 3 } }, + body: { + kind: 'block', + lines: [ + // Spawn the busy probe AND increment count so over flips. + // The increment lands BEFORE busy resolves so subsequent + // re-evals see updated counter, but several tasks can be + // simultaneously waiting in busy. + { + kind: 'set', + path: [{ prop: 'count' }], + value: { + kind: 'get', + path: [ + { prop: 'count' }, { prop: 'add' }, + { args: { other: { kind: 'new', type: { name: 'num' }, value: 1 } } }, + ], + }, + }, + { kind: 'native', id: 'test.busy' }, + ], + }, + }, + } as const; + + const start = Date.now(); + await e2.run(program); + const elapsed = Date.now() - start; + + // 6 iterations × 50ms with concurrency 3 = 2 batches × 50ms ≈ 100ms. + expect(max).toBeGreaterThanOrEqual(2); + expect(max).toBeLessThanOrEqual(3); + expect(elapsed).toBeLessThan(200); }); test('non-iterable, non-bool over still flags loop.not-iterable', () => { diff --git a/packages/gin/src/__tests__/map.test.ts b/packages/gin/src/__tests__/map.test.ts index 8c6f0d8..7b7ae7e 100644 --- a/packages/gin/src/__tests__/map.test.ts +++ b/packages/gin/src/__tests__/map.test.ts @@ -69,7 +69,7 @@ describe('MapType', () => { test('at method returns optional V', () => { const p = r.map(r.text(), r.num()).props(); - expect(p.at?.type.name).toBe('function'); + expect(p.at?.type.name).toBe('fn'); }); test('encode + parse roundtrip', () => { diff --git a/packages/gin/src/__tests__/natives-collections.test.ts b/packages/gin/src/__tests__/natives-collections.test.ts index 34c57ba..087724e 100644 --- a/packages/gin/src/__tests__/natives-collections.test.ts +++ b/packages/gin/src/__tests__/natives-collections.test.ts @@ -74,7 +74,7 @@ describe('list natives', () => { }); const gt2 = { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'bool' } } }, body: { kind: 'get', path: [{ prop: 'args' }, { prop: 'value' }, { prop: 'gt' }, { args: { other: { kind: 'new', type: { name: 'num' }, value: 2 } } }] }, }; expect((await e.run(program(gt2, 'some'))).raw).toBe(true); diff --git a/packages/gin/src/__tests__/readme.test.ts b/packages/gin/src/__tests__/readme.test.ts index 1423d45..797d0c0 100644 --- a/packages/gin/src/__tests__/readme.test.ts +++ b/packages/gin/src/__tests__/readme.test.ts @@ -67,7 +67,7 @@ describe('README examples', () => { fn: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'bool' } }, }, body: { diff --git a/packages/gin/src/__tests__/recurse.test.ts b/packages/gin/src/__tests__/recurse.test.ts index e34d138..42c626c 100644 --- a/packages/gin/src/__tests__/recurse.test.ts +++ b/packages/gin/src/__tests__/recurse.test.ts @@ -19,7 +19,7 @@ describe('recurse in lambda body', () => { value: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, @@ -217,7 +217,7 @@ describe('recurse in CallDef.get', () => { test('JSON-declared callable recurses via `recurse`', async () => { const r = createRegistry(); const countdownFn = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, @@ -281,7 +281,7 @@ describe('recurse in CallDef.set (method)', () => { const r = createRegistry(); // x.drain({k}) = _ — walks k..0, pushing each to log via recurse. const drainFn = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { k: { type: { name: 'num' } } } }, returns: { name: 'num' }, @@ -367,7 +367,7 @@ describe('recurse in CallDef.set (direct call)', () => { test('direct-call setter recurses', async () => { const r = createRegistry(); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { k: { type: { name: 'num' } } } }, returns: { name: 'num' }, diff --git a/packages/gin/src/__tests__/registry.test.ts b/packages/gin/src/__tests__/registry.test.ts index 69ec1d1..77053fc 100644 --- a/packages/gin/src/__tests__/registry.test.ts +++ b/packages/gin/src/__tests__/registry.test.ts @@ -71,7 +71,7 @@ describe('Registry', () => { test('method helper builds fn-typed prop', () => { const r = createRegistry(); const p = r.method({ other: r.num() }, r.bool(), 'x.method'); - expect(p.type.name).toBe('function'); + expect(p.type.name).toBe('fn'); expect((p.get as any).id).toBe('x.method'); }); diff --git a/packages/gin/src/__tests__/scopes-typedef.test.ts b/packages/gin/src/__tests__/scopes-typedef.test.ts index 7dbe499..3b12624 100644 --- a/packages/gin/src/__tests__/scopes-typedef.test.ts +++ b/packages/gin/src/__tests__/scopes-typedef.test.ts @@ -439,7 +439,7 @@ describe('CallDef.get body', () => { const r = createRegistry(); const e = new Engine(r); const fnType = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { x: { type: { name: 'num' } } } }, returns: { name: 'num' }, @@ -515,7 +515,7 @@ describe('PathCall.catch scope', () => { value: { kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' }, diff --git a/packages/gin/src/__tests__/super-override.test.ts b/packages/gin/src/__tests__/super-override.test.ts index cf88581..4d3e2db 100644 --- a/packages/gin/src/__tests__/super-override.test.ts +++ b/packages/gin/src/__tests__/super-override.test.ts @@ -343,7 +343,7 @@ describe('super in CallDef.set (method call.set) override', () => { const r = createRegistry(); // Base method has call.set that pushes (args.k, value) onto baseLog. const baseFn = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, @@ -381,7 +381,7 @@ describe('super in CallDef.set (method call.set) override', () => { // Override: push into overrideLog, then super({args: args, value: value + 1000}). const overrideFn = r.parse({ - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { k: { type: { name: 'text' } } } }, returns: { name: 'num' }, diff --git a/packages/gin/src/__tests__/toCode.test.ts b/packages/gin/src/__tests__/toCode.test.ts index 1614426..f579ce3 100644 --- a/packages/gin/src/__tests__/toCode.test.ts +++ b/packages/gin/src/__tests__/toCode.test.ts @@ -310,13 +310,13 @@ describe('Engine.toCode — expressions', () => { condition: { kind: 'new', type: { name: 'bool' }, value: true }, body: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'flow', action: 'return', value: { kind: 'new', type: { name: 'num' }, value: 7 } }, }, }], else: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }, { expectsValue: true }); @@ -362,7 +362,7 @@ describe('Engine.toCode — expressions', () => { const code = e.toCode({ kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' } }, }, body: { diff --git a/packages/gin/src/__tests__/toSchema.test.ts b/packages/gin/src/__tests__/toSchema.test.ts index 6a2c04a..0d513d5 100644 --- a/packages/gin/src/__tests__/toSchema.test.ts +++ b/packages/gin/src/__tests__/toSchema.test.ts @@ -67,7 +67,7 @@ describe('toSchema / buildSchemas', () => { expect(() => Expr.parse({ kind: 'lambda', type: { - name: 'function', + name: 'fn', call: { args: { name: 'obj', props: { n: { type: { name: 'num' } } } }, returns: { name: 'num' }, diff --git a/packages/gin/src/__tests__/validate-set.test.ts b/packages/gin/src/__tests__/validate-set.test.ts index 99f6142..e4529ee 100644 --- a/packages/gin/src/__tests__/validate-set.test.ts +++ b/packages/gin/src/__tests__/validate-set.test.ts @@ -133,7 +133,7 @@ describe('validate set — negative cases (errors flagged)', () => { name: 'fn', value: { kind: 'lambda', - type: { name: 'function', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, + type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'num' } } }, body: { kind: 'new', type: { name: 'num' }, value: 0 }, }, }], diff --git a/packages/gin/src/exprs/lambda.ts b/packages/gin/src/exprs/lambda.ts index dffbbb9..5c328d8 100644 --- a/packages/gin/src/exprs/lambda.ts +++ b/packages/gin/src/exprs/lambda.ts @@ -55,7 +55,7 @@ export class LambdaExpr extends Expr { kind: z.literal('lambda'), ...baseExprFields, type: opts.Type.describe( - 'The lambda\'s function type — `{ name: "function", call: { args, returns } }` (or a registered named fn type). The `args` obj defines what the body sees under the `args` scope variable; `returns` is what the body must produce.', + 'The lambda\'s function type — `{ name: "fn", call: { args, returns } }` (or a registered named fn type). The `args` obj defines what the body sees under the `args` scope variable; `returns` is what the body must produce.', ), body: opts.Expr.describe( 'The lambda body. At runtime, scope contains the lexical scope at definition site PLUS `args` (the call arguments) and `recurse` (this same lambda, for self-calls). Read params via `[{prop:"args"},{prop:""}]`.', diff --git a/packages/gin/src/exprs/loop.ts b/packages/gin/src/exprs/loop.ts index 538b52d..4d80efa 100644 --- a/packages/gin/src/exprs/loop.ts +++ b/packages/gin/src/exprs/loop.ts @@ -84,7 +84,8 @@ export class LoopExpr extends Expr { }) .optional() .describe( - 'Opt-in parallelism. Both fields are optional and independent: `concurrent` caps fan-out width, `rate` paces start times. Iterations may finish out of order; the body should not assume sequential ordering.', + 'Opt-in parallelism. Both fields are optional and independent: `concurrent` caps fan-out width, `rate` paces start times. Iterations may finish out of order; the body should not assume sequential ordering. ' + + 'Composes with dynamic (bool while-loop) iteration: the body fans out up to `concurrent`, and `over` is re-evaluated against the outer scope each time a task completes — so accumulating side effects of earlier tasks decide whether more tasks spawn.', ), }).meta({ aid: 'Expr_loop' }); } @@ -103,29 +104,106 @@ export class LoopExpr extends Expr { const keyName = this.keyName ?? 'key'; const valueName = this.valueName ?? 'value'; + // Read parallel options up front — both dynamic and static modes + // honor them. Dynamic mode requires concurrency to be bounded for + // parallel to be meaningful (otherwise every "tick" of `over` + // would race the body's side effects in unbounded ways), so we + // treat unbounded-concurrent + dynamic as sequential. + const concurrent = this.parallel?.concurrent + ? Number((await this.parallel.concurrent.evaluate(engine, scope)).raw) + : undefined; + const rateMs = this.parallel?.rate + ? Number((await this.parallel.rate.evaluate(engine, scope)).raw) + : undefined; + const parallel = concurrent !== undefined || rateMs !== undefined; + // Dynamic mode: re-evaluate `over` against the OUTER scope each - // iteration; continue while the value's `raw` is truthy. Body - // mutations (via `set`) on vars the expression reads drive the + // iteration. Continue while the value's `raw` is truthy. Body + // mutations (via `set` on vars the expression reads) drive the // exit condition. `key` is the iteration index, `value` is the - // current re-evaluated value. Bool's GetSet sets this flag for - // while-loop semantics; other types can opt in similarly. Parallel - // options aren't meaningful in this mode (analyzer warns). + // current re-evaluated value. if (gs.loopDynamic) { - let current: Value = over; + const indexType = engine.registry.num({ whole: true, min: 0 }); + + // Sequential: simple while-loop. + if (!parallel) { + let current: Value = over; + let iteration = 0; + while (current.raw) { + const iter = scope.child({ + [keyName]: val(indexType, iteration), + [valueName]: current, + }); + try { + await this.body.evaluate(engine, iter); + } catch (sig) { + if (sig instanceof BreakSignal) break; + if (!(sig instanceof ContinueSignal)) throw sig; + } + iteration++; + current = await this.over.evaluate(engine, scope); + } + return val(engine.registry.void(), undefined); + } + + // Dynamic + parallel: spawn up to `concurrent` tasks; whenever + // ANY task completes, re-evaluate `over` against the outer + // scope and — if still truthy — spawn another. The re-eval + // happens AFTER each completion (not before each start) so + // body side effects from the previous batch are visible before + // the next decision. Rate-limits delay starts the same way as + // static mode. + const pool: Set> = new Set(); + const maxConcurrent = concurrent ?? Infinity; + let broken = false; + let lastStart = 0; let iteration = 0; - while (current.raw) { + + const trySpawn = async (): Promise => { + if (broken) return false; + const current = await this.over.evaluate(engine, scope); + if (!current.raw) return false; + if (rateMs && rateMs > 0) { + const now = Date.now(); + const delta = now - lastStart; + if (delta < rateMs) await new Promise((r) => setTimeout(r, rateMs - delta)); + lastStart = Date.now(); + } const iter = scope.child({ - [keyName]: val(engine.registry.num({ whole: true, min: 0 }), iteration), + [keyName]: val(indexType, iteration), [valueName]: current, }); - try { - await this.body.evaluate(engine, iter); - } catch (sig) { - if (sig instanceof BreakSignal) break; - if (!(sig instanceof ContinueSignal)) throw sig; - } + const task = (async () => { + try { + await this.body.evaluate(engine, iter); + } catch (sig) { + if (sig instanceof ContinueSignal) return; + if (sig instanceof BreakSignal) { broken = true; return; } + throw sig; + } + })(); + const wrapped = task.finally(() => pool.delete(wrapped)); + pool.add(wrapped); iteration++; - current = await this.over.evaluate(engine, scope); + return true; + }; + + // Initial fill — bring the pool up to capacity (or until `over` + // becomes falsy). For unbounded-concurrent, spawn ONE task and + // let the drain loop step the rest sequentially. + const initialCap = Number.isFinite(maxConcurrent) ? maxConcurrent : 1; + while (pool.size < initialCap) { + const ok = await trySpawn(); + if (!ok) break; + } + // Drain — every completion re-evaluates and possibly fills the + // freed slot. + while (pool.size > 0) { + await Promise.race(pool); + while (!broken && pool.size < initialCap) { + const ok = await trySpawn(); + if (!ok) break; + } } return val(engine.registry.void(), undefined); } @@ -138,15 +216,6 @@ export class LoopExpr extends Expr { throw new Error(`loop: type '${over.type.name}' has no loop ExprDef on its GetSet`); } - const concurrent = this.parallel?.concurrent - ? Number((await this.parallel.concurrent.evaluate(engine, scope)).raw) - : undefined; - const rateMs = this.parallel?.rate - ? Number((await this.parallel.rate.evaluate(engine, scope)).raw) - : undefined; - - const parallel = concurrent !== undefined || rateMs !== undefined; - if (!parallel) { const yieldFn = async (keyVal: Value, valueVal: Value): Promise => { const iter = scope.child({ [keyName]: keyVal, [valueName]: valueVal }); @@ -211,9 +280,6 @@ export class LoopExpr extends Expr { if (!iterable) { p.error('loop.not-iterable', `type '${overT.name}' has no loop defined`); } - if (gs?.loopDynamic && this.parallel) { - p.error('loop.parallel.dynamic', 'parallel options (concurrent / rate) are not meaningful for a dynamic (re-evaluated) loop'); - } // parallel.concurrent must be num; parallel.rate must be num or duration. if (this.parallel?.concurrent) { diff --git a/packages/gin/src/types/fn.ts b/packages/gin/src/types/fn.ts index 7c49d80..a8d275e 100644 --- a/packages/gin/src/types/fn.ts +++ b/packages/gin/src/types/fn.ts @@ -18,7 +18,7 @@ import { callDefSchema } from '../schemas'; * shape apply meaningfully to function bodies. */ export class FnType extends Type> { - static readonly NAME = 'function'; + static readonly NAME = 'fn'; /** fn's signature IS its structure — call is natively consumed. */ static readonly consumes = ['call'] as const; readonly name = FnType.NAME; @@ -57,7 +57,7 @@ export class FnType extends Type> { static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ - name: z.literal('function'), + name: z.literal('fn'), call: callDefSchema(opts).optional(), }).meta({ aid: 'Type_function' }); } diff --git a/packages/ginny/src/prompts/designer.ts b/packages/ginny/src/prompts/designer.ts index fdca456..88afca0 100644 --- a/packages/ginny/src/prompts/designer.ts +++ b/packages/ginny/src/prompts/designer.ts @@ -97,7 +97,7 @@ const createNewFn = ai.tool({ let returnsType: Type; try { const fnDef: TypeDef = { - name: 'function', + name: 'fn', call: { ...(input.types ? { types: input.types } : {}), args: input.args, @@ -356,7 +356,7 @@ const editFn = ai.tool({ let newReturnsType: Type; try { const fnDef: TypeDef = { - name: 'function', + name: 'fn', call: { ...(input.types ? { types: input.types } : {}), args: input.args, diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index 5416e66..d1b007e 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -179,7 +179,10 @@ when comparing one expression against several literal values. bool reads. Combine with \`flow:break\`/\`flow:continue\` for explicit early exit. - \`parallel\`: optional concurrency hints (\`concurrent: num\`, - \`rate: num\` per-second). + \`rate: num\` per-second). Composes with bool while-loop mode: + the body fans out up to \`concurrent\`, and \`over\` is re-evaluated + each time a task COMPLETES — so prior tasks' side effects decide + whether more tasks spawn. \`\`\`json // for each task in tasks: do something { "kind": "loop", @@ -199,7 +202,7 @@ the body each call (must return \`bool\`); throws on false. \`\`\`json // (args: { value: num }) => args.value + 1 { "kind": "lambda", - "type": { "name": "function", + "type": { "name": "fn", "call": { "args": { "name": "obj", "props": { "value": { "type": { "name": "num" } } } }, "returns": { "name": "num" } } }, "body": { "kind": "get", "path": [ @@ -696,10 +699,14 @@ Padding with defaults like \`prefix: ""\` adds visual noise. Combine with \`flow:break\` / \`flow:continue\` for explicit early exit. For state that evolves across iterations, use \`set\` exprs in the body to mutate the variables the bool expression reads. -- **Function types are \`{name: 'function', call: {args, returns}}\`.** + Adding \`parallel: { concurrent: N }\` to a bool over fans the body + out up to N in-flight; \`over\` is re-evaluated each time a task + completes, so accumulating side effects from earlier tasks decide + whether more spawn. +- **Function types are \`{name: 'fn', call: {args, returns}}\`.** Do NOT invent obj shapes with a \`returns\` key as a fn type. If you find yourself writing \`type: { args: ..., returns: ... }\` - without \`name: 'function'\`, that's wrong. + without \`name: 'fn'\`, that's wrong. - **Mutating a local var is a \`set\` expr.** Use \`{ kind: "set", path: [{prop: "varName"}], value: }\`. Never write \`varName = ...\` — that's TypeScript syntax, not a gin ExprDef. diff --git a/packages/ginny/src/tools/finish.ts b/packages/ginny/src/tools/finish.ts index bfb3c71..d9f1828 100644 --- a/packages/ginny/src/tools/finish.ts +++ b/packages/ginny/src/tools/finish.ts @@ -76,7 +76,7 @@ export const finish = ai.tool({ // alias references intact. const useAliases = useTarget && ctx.targetFn?.callTypes && ctx.targetFn?.sourceArgs && ctx.targetFn?.sourceReturns; const fnTypeDef: TypeDef = { - name: 'function', + name: 'fn', ...(input.docs ? { docs: input.docs } : {}), call: useAliases ? { From 4fbaecdb0236491a3fedf75b76d5c2a688dd8e85 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Fri, 1 May 2026 08:40:48 -0400 Subject: [PATCH 12/21] Introduce @aeye/gin and ginny updates Add higher-level docs for @aeye/gin and @aeye/ginny to README; include install/usage notes and feature summaries. Bump @aeye/gin package to v0.3.8 and move puppeteer to optionalDependencies in package metadata. Tighten several gin expression schemas by removing comment/baseExprFields allowances (GetExpr, NewExpr, TemplateExpr) to enforce stricter shapes. Add GPL-3.0 LICENSE for ginny and introduce new runtime signal utilities (runtime-signal.ts, signal-utils.ts) along with multiple ginny source updates (ai, index, natives, tools, web content, esbuild config, package.json). Updated lockfile to reflect dependency changes. --- README.md | 33 ++ package-lock.json | 82 ++- packages/gin/package.json | 2 +- packages/gin/src/exprs/get.ts | 1 - packages/gin/src/exprs/new.ts | 5 - packages/gin/src/exprs/template.ts | 2 - packages/ginny/LICENSE | 674 +++++++++++++++++++++++ packages/ginny/esbuild.config.cjs | 6 + packages/ginny/package.json | 6 +- packages/ginny/src/ai.ts | 38 +- packages/ginny/src/index.ts | 97 +++- packages/ginny/src/natives/fetch.ts | 6 + packages/ginny/src/natives/llm.ts | 62 ++- packages/ginny/src/runtime-signal.ts | 23 + packages/ginny/src/signal-utils.ts | 40 ++ packages/ginny/src/tools/print-fn.ts | 9 + packages/ginny/src/tools/search-fns.ts | 16 + packages/ginny/src/tools/web-get-page.ts | 9 +- packages/ginny/src/tools/web-search.ts | 13 +- packages/ginny/src/web-content.ts | 19 +- 20 files changed, 1095 insertions(+), 48 deletions(-) create mode 100644 packages/ginny/LICENSE create mode 100644 packages/ginny/src/runtime-signal.ts create mode 100644 packages/ginny/src/signal-utils.ts diff --git a/README.md b/README.md index 4f8f0b7..1302b11 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ To see a complex example of a CLI agent built with aeye - `npm i -g @aeye/cletus` and run `cletus`! +For a higher-level "build with types" experience, check out **[@aeye/gin](./packages/gin)** (a JSON-typed, executable program language for LLMs) and **[@aeye/ginny](./packages/ginny)** (a CLI that turns natural-language requests into validated gin programs) — `npm i -g @aeye/ginny` and run `ginny`. + ```ts import { AI } from '@aeye/ai'; import { OpenAIProvider } from '@aeye/openai'; @@ -227,6 +229,35 @@ npm install @aeye/aws - Text embeddings (Amazon Titan) - Automatic AWS credential discovery +### Higher-Level Packages + +#### [@aeye/gin](./packages/gin) +A JSON-based programming language and type system designed for LLMs to author, validate, and execute typed programs at runtime. Gives the model a real type system (generics, structural compatibility, extension-based inheritance) and an expression language serialized as plain JSON — programs round-trip through `JSON.stringify` / `JSON.parse`, can be introspected and validated without running them, and execute in-process against a pluggable registry of native functions. + +```bash +npm install @aeye/gin zod +``` + +**Features:** +- Typed expressions (`get`, `set`, `define`, `loop`, `if`, `switch`, `lambda`, `flow`, `native`, …) authored as JSON +- Static `validate()` catches unknown vars, prop / type mismatches, out-of-place flow before execution +- Generics with constraints (not defaults), structural type compatibility, type augmentation via `registry.augment(...)` +- Sequential and parallel loops over lists, maps, objs, text, num — plus dynamic (bool while-loop) iteration that composes with parallelism + +#### [@aeye/ginny](./packages/ginny) +CLI agent that turns natural-language requests into executable gin programs. Multi-prompt orchestration (programmer → designer → architect → researcher → DBA) drafts, validates, and persists reusable typed functions and variables to disk, with a path-callable native fn surface (`fns.fetch`, `fns.llm`, `fns.log`, `fns.ask`) and optional Tavily-powered web research. + +```bash +npm install -g @aeye/ginny +ginny +``` + +**Features:** +- REPL with conversation history, ESC-to-interrupt, Ctrl+C exit +- Per-prompt model overrides via `GIN__MODEL` env vars (programmer, researcher, architect, designer, dba, llm) +- Fn / type / var catalog persisted as JSON under `./fns`, `./types`, `./vars` for reuse across sessions +- Works with any provider configured for `@aeye/ai` — OpenAI, OpenRouter, AWS Bedrock; web research via Tavily + ## Usage Examples ### Chat Completion @@ -766,6 +797,8 @@ aeye/ │ ├── openrouter/ # OpenRouter provider │ ├── replicate/ # Replicate provider │ ├── aws/ # AWS Bedrock provider +│ ├── gin/ # JSON-typed program language for LLMs +│ ├── ginny/ # CLI agent that authors gin programs │ └── cletus/ # Example CLI agent ├── package.json # Root package configuration └── tsconfig.json # TypeScript configuration diff --git a/package-lock.json b/package-lock.json index 217d8c8..94633e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -987,6 +987,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -1295,6 +1296,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4530,6 +4532,7 @@ "version": "2.10.13", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", @@ -4551,6 +4554,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4560,6 +4564,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4575,6 +4580,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -4589,6 +4595,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4598,6 +4605,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4610,6 +4618,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4624,6 +4633,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4636,6 +4646,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -4653,6 +4664,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "devOptional": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -6097,6 +6109,7 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -7293,6 +7306,7 @@ "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -7372,6 +7386,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -7544,6 +7559,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -7665,6 +7681,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7822,6 +7839,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "devOptional": true, "license": "MIT", "engines": { "node": "*" @@ -7884,6 +7902,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -8158,6 +8177,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -8171,6 +8191,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -8558,6 +8579,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "devOptional": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -8584,12 +8606,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10181,6 +10205,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14" @@ -10319,6 +10344,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "devOptional": true, "license": "MIT", "dependencies": { "ast-types": "^0.13.4", @@ -10399,6 +10425,7 @@ "version": "0.0.1521046", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/diff": { @@ -10581,6 +10608,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "devOptional": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -10616,6 +10644,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -10638,6 +10667,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -10777,6 +10807,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -10798,6 +10829,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "devOptional": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -10811,6 +10843,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -10840,6 +10873,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -10876,6 +10910,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -10962,6 +10997,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -10982,6 +11018,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "devOptional": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -11004,6 +11041,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -11087,6 +11125,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "devOptional": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -11433,6 +11472,7 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "devOptional": true, "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", @@ -11942,6 +11982,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -12018,6 +12059,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -12034,6 +12076,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -12299,6 +12342,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 12" @@ -12345,6 +12389,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, "license": "MIT" }, "node_modules/is-buffer": { @@ -13543,6 +13588,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "devOptional": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -13576,6 +13622,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -14054,6 +14101,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true, "license": "MIT" }, "node_modules/load-tsconfig": { @@ -15364,6 +15412,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "devOptional": true, "license": "MIT" }, "node_modules/mkdirp": { @@ -15470,6 +15519,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -15604,6 +15654,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -15886,6 +15937,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "devOptional": true, "license": "MIT", "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", @@ -15905,6 +15957,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "devOptional": true, "license": "MIT", "dependencies": { "degenerator": "^5.0.0", @@ -15938,6 +15991,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -15975,6 +16029,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -16117,12 +16172,14 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -16390,6 +16447,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -16409,6 +16467,7 @@ "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -16424,6 +16483,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "devOptional": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -16434,6 +16494,7 @@ "version": "24.31.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.31.0.tgz", "integrity": "sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -16455,6 +16516,7 @@ "version": "24.31.0", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.31.0.tgz", "integrity": "sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.10.13", @@ -17307,6 +17369,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -17317,6 +17380,7 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "devOptional": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -17331,6 +17395,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -17457,6 +17522,7 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "devOptional": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -17864,6 +17930,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "devOptional": true, "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -17878,6 +17945,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "devOptional": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -17965,6 +18033,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -18891,6 +18960,7 @@ "version": "2.12.0", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "devOptional": true, "license": "MIT" }, "node_modules/typescript": { @@ -19614,6 +19684,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz", "integrity": "sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/which": { @@ -19887,6 +19958,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -20012,6 +20084,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -20074,6 +20147,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "devOptional": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -22162,7 +22236,7 @@ }, "packages/gin": { "name": "@aeye/gin", - "version": "0.1.0", + "version": "0.3.8", "license": "GPL-3.0", "dependencies": { "zod": "^4.1.12" @@ -22185,7 +22259,6 @@ "mammoth": "^1.8.0", "node-html-markdown": "^1.3.0", "pdf-parse": "^1.1.1", - "puppeteer": "^24.0.0", "xlsx": "^0.18.5" }, "bin": { @@ -22195,7 +22268,7 @@ "@aeye/ai": "0.3.8", "@aeye/aws": "0.3.8", "@aeye/core": "0.3.8", - "@aeye/gin": "0.1.0", + "@aeye/gin": "0.3.8", "@aeye/models": "0.3.8", "@aeye/openai": "0.3.8", "@aeye/openrouter": "0.3.8", @@ -22209,6 +22282,9 @@ }, "engines": { "node": ">=18.0.0" + }, + "optionalDependencies": { + "puppeteer": "^24.0.0" } }, "packages/ginny/node_modules/@esbuild/aix-ppc64": { diff --git a/packages/gin/package.json b/packages/gin/package.json index be47641..f1c9ffe 100644 --- a/packages/gin/package.json +++ b/packages/gin/package.json @@ -1,6 +1,6 @@ { "name": "@aeye/gin", - "version": "0.1.0", + "version": "0.3.8", "description": "Gin - A type & expression system for LLM agents", "type": "module", "main": "dist/index.js", diff --git a/packages/gin/src/exprs/get.ts b/packages/gin/src/exprs/get.ts index 4917021..110c930 100644 --- a/packages/gin/src/exprs/get.ts +++ b/packages/gin/src/exprs/get.ts @@ -32,7 +32,6 @@ export class GetExpr extends Expr { static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('get'), - // No `comment` field — see header comment above. path: z .array(pathStepSchema(opts)) .describe( diff --git a/packages/gin/src/exprs/new.ts b/packages/gin/src/exprs/new.ts index 20bf41b..08cfc59 100644 --- a/packages/gin/src/exprs/new.ts +++ b/packages/gin/src/exprs/new.ts @@ -42,11 +42,6 @@ export class NewExpr extends Expr { } static toSchema(opts: SchemaOptions): z.ZodTypeAny { - // No `comment` field on any branch below — `new` is a literal / - // constructor; the type + value already convey what it is, so an - // attached comment is pure noise. Strict-mode schema rejects it - // outright. Comments belong on statement-shaped Exprs only. - // // Strict mode: emit a discriminated union over every Type the LLM // could legitimately `new`: // - One branch per built-in Type class: `type` is that class's full diff --git a/packages/gin/src/exprs/template.ts b/packages/gin/src/exprs/template.ts index e97c65d..61bdb99 100644 --- a/packages/gin/src/exprs/template.ts +++ b/packages/gin/src/exprs/template.ts @@ -11,7 +11,6 @@ import { Expr, type ValidateContext, type ChildVisitor } from '../expr'; import type { CodeOptions, SchemaOptions } from '../node'; import { NewExpr } from './new'; import { z } from 'zod'; -import { baseExprFields } from '../schemas'; import type { TypeScope } from '../type-scope'; /** @@ -44,7 +43,6 @@ export class TemplateExpr extends Expr { static toSchema(opts: SchemaOptions): z.ZodTypeAny { return z.object({ kind: z.literal('template'), - ...baseExprFields, template: z.union([opts.Expr, z.string()]).describe( 'The template string — either a literal string (auto-wrapped as `new text`) or an Expr that evaluates to text. Placeholders use `{name}` syntax; each `name` must appear as a key on `params`.', ), diff --git a/packages/ginny/LICENSE b/packages/ginny/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/packages/ginny/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/ginny/esbuild.config.cjs b/packages/ginny/esbuild.config.cjs index 26153b7..29873ad 100644 --- a/packages/ginny/esbuild.config.cjs +++ b/packages/ginny/esbuild.config.cjs @@ -39,6 +39,12 @@ esbuild.build({ format: 'esm', plugins: [shebangPlugin], banner: esmBanner, + // `puppeteer` is heavy (Chromium download ~170 MB) and only used by + // web fetching. Externalize it so the bundle does a runtime + // `require('puppeteer')` instead of inlining it — paired with + // `optionalDependencies` in package.json, this lets users skip the + // Chromium install entirely if they don't need web fetching. + external: ['puppeteer'], define: { 'process.env.NODE_ENV': '"production"', }, diff --git a/packages/ginny/package.json b/packages/ginny/package.json index 7797856..bfa64d5 100644 --- a/packages/ginny/package.json +++ b/packages/ginny/package.json @@ -41,14 +41,16 @@ "mammoth": "^1.8.0", "node-html-markdown": "^1.3.0", "pdf-parse": "^1.1.1", - "puppeteer": "^24.0.0", "xlsx": "^0.18.5" }, + "optionalDependencies": { + "puppeteer": "^24.0.0" + }, "devDependencies": { "@aeye/ai": "0.3.8", "@aeye/aws": "0.3.8", "@aeye/core": "0.3.8", - "@aeye/gin": "0.1.0", + "@aeye/gin": "0.3.8", "@aeye/models": "0.3.8", "@aeye/openai": "0.3.8", "@aeye/openrouter": "0.3.8", diff --git a/packages/ginny/src/ai.ts b/packages/ginny/src/ai.ts index b8766d6..109aa5f 100644 --- a/packages/ginny/src/ai.ts +++ b/packages/ginny/src/ai.ts @@ -13,6 +13,7 @@ import { createFetchImpl, registerFetchType } from './natives/fetch'; import { createLlmImpl, registerLlmType } from './natives/llm'; import { createLogImpl, registerLogType } from './natives/log'; import { createAskImpl, registerAskType } from './natives/ask'; +import { MODEL_KEYS } from './model-selection'; // Hydrate process.env from config.json before anything reads env vars. // Safe: imported modules above just declare classes; no env-var reads run yet. @@ -69,7 +70,11 @@ const awsChatHooks = { }, }; -async function buildProviders(): Promise<{ providers: Record; enabled: string[] }> { +async function buildProviders(): Promise<{ + providers: Record; + enabled: string[]; + skipped: string[]; +}> { const enabled: string[] = []; const skipped: string[] = []; const providers: Record = {}; @@ -123,11 +128,9 @@ async function buildProviders(): Promise<{ providers: Record; ); } - const tavily = process.env['TAVILY_API_KEY'] ? ' + web_search (tavily)' : ''; - console.error(`ginny: providers enabled → ${enabled.join(', ')}${tavily}`); - for (const s of skipped) console.error(` skipped → ${s}`); - - return { providers, enabled }; + // Logging the result is the entry point's job — it lives downstream + // of `console.clear()` and prints the full startup banner there. + return { providers, enabled, skipped }; } export const { registry, engine } = bootstrap(); @@ -141,7 +144,28 @@ const sessionLoadedVars = new Map_MODEL` override. Used by the startup banner — empty set + * means selection falls through to the model registry's defaults. */ +const configuredModels = new Set(); +for (const k of MODEL_KEYS) { + const v = process.env[`GIN_${k.toUpperCase()}_MODEL`]; + if (v && v.trim()) configuredModels.add(v.trim()); +} +const fallback = process.env['GIN_MODEL']; +if (fallback && fallback.trim()) configuredModels.add(fallback.trim()); + +/** Snapshot of provider/model/feature state captured at AI bootstrap. + * The entry point reads this after clearing the screen so the user + * sees a clean startup summary. */ +export const aiInfo = { + providers: enabledProviderNames, + skipped: skippedProviderReasons, + models: configuredModels, + webSearch: !!process.env['TAVILY_API_KEY'], +}; // Model selection picks the top-scored model across every entry in `models`. // If we don't restrict `providers.allow` to the set of providers we actually diff --git a/packages/ginny/src/index.ts b/packages/ginny/src/index.ts index 3f664e7..9b8dd3f 100644 --- a/packages/ginny/src/index.ts +++ b/packages/ginny/src/index.ts @@ -4,6 +4,8 @@ import type { Message } from '@aeye/core'; import { programmer } from './prompts/programmer'; import { EventDisplay } from './event-display'; import { logger } from './logger'; +import { aiInfo } from './ai'; +import { setRuntimeSignal } from './runtime-signal'; /** * Single readline interface used for both the REPL prompt loop AND for @@ -19,6 +21,10 @@ const rl = readline.createInterface({ terminal: true, }); +// Emit `keypress` events on stdin so we can listen for ESC during an +// in-flight request and use it to interrupt the run cleanly. +readline.emitKeypressEvents(process.stdin); + /** * Resolve with the user's typed answer. Wired into every prompt's ctx * as `ask`, surfacing the `ask` tool to the model. Writes the question @@ -87,7 +93,7 @@ function startSpinner(label: string): () => void { const history: Message[] = []; async function runRequest(request: string): Promise { - const stopSpinner = startSpinner('ginny is thinking…'); + const stopSpinner = startSpinner('ginny is thinking… (ESC to interrupt)'); let spinnerStopped = false; const ensureSpinnerStopped = () => { if (!spinnerStopped) { @@ -96,11 +102,53 @@ async function runRequest(request: string): Promise { } }; - // Wire Ctrl+C during a request so we can abort the stream cleanly - // without tearing down the whole REPL. + // Two ways to abort an in-flight request without killing the REPL: + // ESC — primary interrupt; brings the user back to `> ` + // Ctrl+C — also aborts, kept for muscle memory + // Once the request finishes, both listeners are removed so a Ctrl+C + // at the idle prompt still exits the process via Node's default. + // + // ESC delivery is fiddly across platforms: readline reports it as + // `key.name === 'escape'`; some terminals deliver only the raw + // sequence in `str`; on Windows `data` events can fire ahead of + // keypress decoding. Listen on both channels and match name OR raw + // ESC byte so we don't miss the press. const abort = new AbortController(); - const onSigint = () => abort.abort(); + const triggerInterrupt = (source: string) => { + if (abort.signal.aborted) return; + process.stderr.write(`\n(interrupting via ${source}…)\n`); + abort.abort(); + }; + const onSigint = () => triggerInterrupt('Ctrl+C'); + const onKeypress = ( + str: string | undefined, + key: { name?: string; sequence?: string } | undefined, + ) => { + const isEsc = key?.name === 'escape' + || str === '\x1b' || key?.sequence === '\x1b'; + if (isEsc) triggerInterrupt('ESC'); + }; + const onData = (chunk: Buffer | string) => { + const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + // A bare ESC arrives as a single 0x1b byte; an ESC-prefixed + // sequence (arrow keys, etc.) is two-or-more bytes starting with + // 0x1b. Only treat the lone byte as an interrupt. + if (buf.length === 1 && buf[0] === 0x1b) triggerInterrupt('ESC'); + }; process.on('SIGINT', onSigint); + process.stdin.on('keypress', onKeypress); + process.stdin.on('data', onData); + // Make sure stdin is actually flowing while we wait — readline pauses + // it between `rl.question` calls on some platforms, which would mute + // both keypress and data events. + if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + // Publish the signal for natives (fns.fetch / fns.llm) to forward + // into their underlying I/O — the gin engine doesn't thread ctx + // through native calls, so they can't read it from `ctx.signal`. + setRuntimeSignal(abort.signal); const display = new EventDisplay(); @@ -157,7 +205,46 @@ async function runRequest(request: string): Promise { } } finally { process.off('SIGINT', onSigint); + process.stdin.off('keypress', onKeypress); + process.stdin.off('data', onData); + setRuntimeSignal(undefined); + } +} + +/** + * Render the post-clear startup summary: which providers came up, which + * were skipped (with reasons), the unique set of model IDs the user has + * pinned via env, and whether web research is wired up. When Tavily is + * unset, point the user at the env var so the fix is one step away. + */ +function printStartupBanner(): void { + const lines: string[] = []; + lines.push('ginny ready.'); + lines.push(''); + + const providers = aiInfo.providers.length > 0 + ? aiInfo.providers.join(', ') + : '(none)'; + lines.push(`Providers: ${providers}`); + for (const reason of aiInfo.skipped) { + lines.push(` · skipped ${reason}`); + } + + if (aiInfo.models.size > 0) { + lines.push(`Models: ${[...aiInfo.models].join(', ')}`); + } else { + lines.push('Models: (defaults — no GIN_MODEL or GIN__MODEL set)'); + } + + if (aiInfo.webSearch) { + lines.push('Web research: enabled (tavily)'); + } else { + lines.push('Web research: disabled — set TAVILY_API_KEY in config.json or env to enable (tavily.com)'); } + + lines.push(''); + lines.push('Type a request. ESC interrupts a run, Ctrl+C exits.'); + console.log(lines.join('\n') + '\n'); } async function main() { @@ -171,7 +258,7 @@ async function main() { return; } - console.log('ginny ready. Type a request (Ctrl+C to exit).\n'); + printStartupBanner(); const prompt = () => { rl.question('> ', async (line) => { diff --git a/packages/ginny/src/natives/fetch.ts b/packages/ginny/src/natives/fetch.ts index 80c0390..471520e 100644 --- a/packages/ginny/src/natives/fetch.ts +++ b/packages/ginny/src/natives/fetch.ts @@ -1,5 +1,6 @@ import type { Registry, Value, Type } from '@aeye/gin'; import { val } from '@aeye/gin'; +import { getRuntimeSignal } from '../runtime-signal'; export function createFetchImpl(registry: Registry) { return async (argsValue: Value): Promise => { @@ -23,12 +24,17 @@ export function createFetchImpl(registry: Registry) { const outputType = args['output']?.raw as Type | undefined; + // Forward the entry-point's interrupt signal so an ESC during a + // long fetch tears down the underlying HTTP request immediately. + const signal = getRuntimeSignal(); + let bodyText = ''; try { const resp = await globalThis.fetch(url, { method, headers: Object.keys(headersObj).length > 0 ? headersObj : undefined, body: bodyStr, + signal, }); bodyText = await resp.text(); } catch (e: unknown) { diff --git a/packages/ginny/src/natives/llm.ts b/packages/ginny/src/natives/llm.ts index cd3e60f..86f732e 100644 --- a/packages/ginny/src/natives/llm.ts +++ b/packages/ginny/src/natives/llm.ts @@ -1,7 +1,9 @@ +import { z } from 'zod'; import type { Registry, Value, Type } from '@aeye/gin'; import { val } from '@aeye/gin'; import type { AI } from '@aeye/ai'; import { modelFor } from '../model-selection'; +import { getRuntimeSignal } from '../runtime-signal'; export function createLlmImpl(registry: Registry, ai: AI) { return async (argsValue: Value): Promise => { @@ -9,7 +11,29 @@ export function createLlmImpl(registry: Registry, ai: AI) { const promptText = (args['prompt']?.raw ?? '') as string; const outputType = args['output']?.raw as Type | undefined; - const schema = outputType ? outputType.toValueSchema() : undefined; + // Two shapes pass straight through to the AI layer: + // - `z.ZodObject` — uses OpenAI's structured-output channel + // (`response_format: json_schema`, which requires `type: + // "object"` at the root). + // - `z.ZodString` — the AI library skips structured output for + // plain-string schemas and returns the model's text directly. + // That's what some models prefer (cheaper, no JSON wrapping + // overhead) and matches what callers expect when `output: text`. + // Anything else (enum, num, bool, list, tuple) is wrapped in a + // `{ value: }` shell so the structured-output channel + // accepts it; we unwrap before parsing so callers see the inner + // value. + const innerSchema = outputType ? outputType.toValueSchema() : undefined; + let promptSchema: z.ZodType | undefined; + let unwrap = false; + if (innerSchema) { + if (innerSchema instanceof z.ZodObject || innerSchema instanceof z.ZodString) { + promptSchema = innerSchema; + } else { + promptSchema = z.object({ value: innerSchema }); + unwrap = true; + } + } const llmPrompt = ai.prompt({ name: 'gin_llm_call', @@ -17,20 +41,31 @@ export function createLlmImpl(registry: Registry, ai: AI) { content: '{{userPrompt}}', input: (input: { prompt: string }) => ({ userPrompt: input.prompt }), metadata: modelFor('llm') as any, - schema: schema as any, + schema: promptSchema, }); - const result = await llmPrompt.get('result', { prompt: promptText }, {} as any); + // Plumb the entry-point's interrupt signal through so an ESC during + // a long llm call cancels the HTTP request rather than hanging. + const signal = getRuntimeSignal(); + const result = await llmPrompt.get( + 'result', + { prompt: promptText }, + ({ signal } as any), + ); + + const finalResult = unwrap && result && typeof result === 'object' + ? (result as { value: unknown }).value + : result; - if (outputType && result !== undefined) { + if (outputType && finalResult !== undefined) { try { - return outputType.parse(result); + return outputType.parse(finalResult); } catch { - return val(registry.text(), JSON.stringify(result)); + return val(registry.text(), JSON.stringify(finalResult)); } } - return val(registry.text(), (result as string) ?? ''); + return val(registry.text(), (finalResult as string) ?? ''); }; } @@ -41,17 +76,14 @@ export function registerLlmType(registry: Registry) { tools: { type: registry.optional(registry.list(registry.any())) }, output: { type: registry.optional(registry.typ(registry.alias('R'))), - docs: 'gin Type to parse the LLM response through — unifies R in the return type.', + docs: 'gin Type to parse the LLM response through — unifies R in the return type. `text` and `obj` types pass straight through (text uses plain-completion mode, obj uses structured-output mode). Other types (enum/num/bool/list/tuple) are auto-wrapped as { value } over the wire and unwrapped before parse, so callers see the inner value.', }, }), registry.alias('R'), undefined, - // Constraint on R, not a default. The LLM call always returns either - // a primitive text reply or a structured `obj` matching whatever - // schema the caller passes via `output`. Anything outside that — - // bool, num, list, etc. — wouldn't round-trip through the model's - // structured-output channel reliably, so reject those bindings at - // call sites. - { R: registry.or([registry.text(), registry.obj({})]) }, + // Constraint on R, not a default. Anything `output:` resolves to is + // wrappable into the LLM's structured-output channel — primitives, + // enums, lists, objs all work. `any` keeps the surface permissive. + { R: registry.any() }, ); } diff --git a/packages/ginny/src/runtime-signal.ts b/packages/ginny/src/runtime-signal.ts new file mode 100644 index 0000000..9acd1b5 --- /dev/null +++ b/packages/ginny/src/runtime-signal.ts @@ -0,0 +1,23 @@ +/** + * Module-level holder for the in-flight request's AbortSignal. + * + * The entry point (`index.ts:runRequest`) calls `setRuntimeSignal` at + * the start of each user request and clears it in `finally`. Native + * implementations (fetch, llm, …) read it via `getRuntimeSignal()` and + * forward it into their underlying I/O so an ESC interrupt cancels + * HTTP requests / streamed completions instead of waiting for them to + * settle. + * + * Tools receive the signal via `ctx.signal` directly — this module is + * specifically for the gin engine path, where natives are invoked + * without ctx threading. + */ +let current: AbortSignal | undefined; + +export function setRuntimeSignal(signal: AbortSignal | undefined): void { + current = signal; +} + +export function getRuntimeSignal(): AbortSignal | undefined { + return current; +} diff --git a/packages/ginny/src/signal-utils.ts b/packages/ginny/src/signal-utils.ts new file mode 100644 index 0000000..8e2cf5f --- /dev/null +++ b/packages/ginny/src/signal-utils.ts @@ -0,0 +1,40 @@ +/** + * Helpers for letting tools and natives respond to the entry-point's + * interrupt signal without each one re-implementing the same plumbing. + * + * - `throwIfAborted` — fast guard at function entry / between major + * awaits. Cheap; intended to bracket long sections of work. + * - `withAbortRace` — wraps a promise in a `Promise.race` against the + * signal so a long-running operation that doesn't natively accept + * AbortSignal (Tavily SDK, puppeteer page.goto, etc.) still unwinds + * when ESC fires. + */ +export class AbortError extends Error { + constructor(reason = 'aborted') { + super(reason); + this.name = 'AbortError'; + } +} + +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new AbortError(); +} + +export function withAbortRace( + promise: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(new AbortError()); + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(new AbortError()); + }; + signal.addEventListener('abort', onAbort); + promise.then( + (v) => { signal.removeEventListener('abort', onAbort); resolve(v); }, + (e) => { signal.removeEventListener('abort', onAbort); reject(e); }, + ); + }); +} diff --git a/packages/ginny/src/tools/print-fn.ts b/packages/ginny/src/tools/print-fn.ts index 7ff056b..c62b78b 100644 --- a/packages/ginny/src/tools/print-fn.ts +++ b/packages/ginny/src/tools/print-fn.ts @@ -52,6 +52,15 @@ export const printFn = ai.tool({ const call = fnType.call(); if (!call) return `// FAILED: '${input.name}' is not a callable type — got ${fnType.name}.`; + // Inspecting a fn's body usually precedes calling it — load it into + // the engine's global scope on the way through so the next `write` + // can reference `{prop:''}` without a separate + // `find_or_create_functions` round-trip. + if (!ctx.loadedFns.has(input.name)) { + ctx.engine.registerGlobal(input.name, { type: fnType, value: null }); + ctx.loadedFns.add(input.name); + } + const generics = renderGenerics(fnType.generic, codeOpts); const params = formatParams(call.args, codeOpts); const returns = call.returns ? call.returns.toCode(undefined, codeOpts) : 'void'; diff --git a/packages/ginny/src/tools/search-fns.ts b/packages/ginny/src/tools/search-fns.ts index 4aa6eb5..898afda 100644 --- a/packages/ginny/src/tools/search-fns.ts +++ b/packages/ginny/src/tools/search-fns.ts @@ -32,6 +32,22 @@ export const searchFns = ai.tool({ ? 'No saved functions yet. Call `find_or_create_functions` to author one.' : `No functions matched [${input.keywords.join(', ')}].`; } + // Surfacing a fn to the model is also a signal it may be used — + // load each result into the engine's global scope so the model can + // call it directly without an extra `find_or_create_functions` + // round-trip. Idempotent: `loadedFns` guards against re-parsing. + for (const r of results) { + if (ctx.loadedFns.has(r.name)) continue; + try { + const typeDef = ctx.store.readFn(r.name); + const fnType = ctx.registry.parse(typeDef); + ctx.engine.registerGlobal(r.name, { type: fnType, value: null }); + ctx.loadedFns.add(r.name); + } catch { + // Bad/missing file — skip; the fn won't appear callable but + // the listing still surfaces its name for diagnostic value. + } + } return results.map((r) => `${r.name}: ${r.summary}`).join('\n'); }, }); diff --git a/packages/ginny/src/tools/web-get-page.ts b/packages/ginny/src/tools/web-get-page.ts index dc9fd7f..dfa46bb 100644 --- a/packages/ginny/src/tools/web-get-page.ts +++ b/packages/ginny/src/tools/web-get-page.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { ai } from '../ai'; import { fetchAndConvert } from '../web-content'; +import { throwIfAborted, withAbortRace } from '../signal-utils'; const PREVIEW_LIMIT = 16000; @@ -27,8 +28,12 @@ export const webGetPage = ai.tool({ url: z.string().describe('URL to fetch'), }), applicable: (ctx) => !!ctx.features?.webSearch, - call: async (input: { url: string }) => { - const result = await fetchAndConvert(input.url); + call: async (input: { url: string }, _refs, ctx) => { + throwIfAborted(ctx.signal); + // Puppeteer's page.goto and pdf-parse / mammoth conversion don't + // accept an AbortSignal — race the whole pipeline against the + // signal so ESC during a slow render unwinds the call. + const result = await withAbortRace(fetchAndConvert(input.url), ctx.signal); if (!result.ok) { return `Error fetching ${input.url}: ${result.error}`; } diff --git a/packages/ginny/src/tools/web-search.ts b/packages/ginny/src/tools/web-search.ts index 0fdfe63..d39eda2 100644 --- a/packages/ginny/src/tools/web-search.ts +++ b/packages/ginny/src/tools/web-search.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { tavily } from '@tavily/core'; import { ai } from '../ai'; +import { throwIfAborted, withAbortRace } from '../signal-utils'; export const webSearch = ai.tool({ name: 'web_search', @@ -10,12 +11,16 @@ export const webSearch = ai.tool({ query: z.string().describe('Search query'), maxResults: z.number().optional().default(5).describe('Max results (default 5)'), }), - call: async (input: { query: string; maxResults?: number }) => { + call: async (input: { query: string; maxResults?: number }, _refs, ctx) => { + throwIfAborted(ctx.signal); try { const client = tavily({ apiKey: process.env['TAVILY_API_KEY']! }); - const resp = await client.search( - input.query, - { maxResults: input.maxResults ?? 5 }, + // Tavily's SDK doesn't expose an AbortSignal — race the request + // against the abort signal so an ESC during the search unwinds + // cleanly instead of waiting for the network round-trip. + const resp = await withAbortRace( + client.search(input.query, { maxResults: input.maxResults ?? 5 }), + ctx.signal, ); const results = resp.results ?? resp; return JSON.stringify(results); diff --git a/packages/ginny/src/web-content.ts b/packages/ginny/src/web-content.ts index b3ffae4..ca90e8b 100644 --- a/packages/ginny/src/web-content.ts +++ b/packages/ginny/src/web-content.ts @@ -12,7 +12,10 @@ * The companion `web_get_page` tool wraps `fetchAndConvert`; consumers * outside of that tool can use the same entry point. */ -import puppeteer from 'puppeteer'; +// puppeteer is loaded lazily — see `fetchHtmlWithPuppeteer`. Keeping +// it out of the top-level import surface lets us declare it as an +// `optionalDependency` so a global install of ginny doesn't force every +// user to download Chromium (~170 MB) just to run non-web flows. import { NodeHtmlMarkdown } from 'node-html-markdown'; import pdfParse from 'pdf-parse'; import * as XLSX from 'xlsx'; @@ -58,6 +61,20 @@ export function detectContentType(contentType: string, url: string): ContentType // --------------------------------------------------------------------------- export async function fetchHtmlWithPuppeteer(url: string): Promise { + // Dynamic import + try/catch so an install where puppeteer (or its + // Chromium download) was skipped surfaces a friendly error pointing + // at the fix, instead of crashing the whole module's import graph. + let puppeteer: typeof import('puppeteer').default; + try { + puppeteer = (await import('puppeteer')).default; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error( + `puppeteer is not available (${msg}). ` + + `Install it to enable JS-rendered HTML fetching: \`npm i -g puppeteer\` ` + + `(this also downloads a bundled Chromium).`, + ); + } const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], From 89723c9c9c979dacc642e0fa60a12d3495680023 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Fri, 1 May 2026 09:49:24 -0400 Subject: [PATCH 13/21] Validate type names; enhance fetch & logging Add strict identifier validation for type 'name', 'extends' and 'satisfies' (zod schema + registry.parse) to catch LLM-emitted junk early. Improve docs/descriptions for text/num type options to discourage over-specification. Harden runtime AbortSignal behaviour by raising listener limits (esbuild banner + CLI setMaxListeners) and patching AbortController so long-running libs/fetch don't hit listener caps. Revamp fns.fetch: add convert modes (markdown|raw), HEADLESS re-rendering for HTML via puppeteer, better content-type handling (binary/text/markdown), user-agent header, and separate typed-JSON path. Introduce genId() and wire 6-char ids into retry logging, LLM parse errors, tool errors, write validation messages, and progress logging so short user-facing one-liners link to full ginny.log context. Improve EventDisplay streaming semantics (stream termination, producedText flag) and make runRequest abort/cleanup behaviour friendlier. Update prompts/docs to describe the three fns.fetch modes, sequencing rules for create_new_fn, and guidance for not embedding JSON programs in prose. --- .../gin/src/__tests__/gaps-satisfies.test.ts | 16 +- packages/gin/src/__tests__/registry.test.ts | 4 +- packages/gin/src/registry.ts | 20 +++ packages/gin/src/schemas.ts | 9 +- packages/gin/src/types/num.ts | 16 +- packages/gin/src/types/text.ts | 10 +- packages/ginny/esbuild.config.cjs | 25 +++ packages/ginny/src/ai.ts | 40 ++++- packages/ginny/src/event-display.ts | 51 +++++- packages/ginny/src/index.ts | 44 ++++- packages/ginny/src/logger.ts | 12 ++ packages/ginny/src/natives/fetch.ts | 103 +++++++++-- packages/ginny/src/natives/llm.ts | 23 ++- packages/ginny/src/progress.ts | 15 +- packages/ginny/src/prompts/designer.ts | 21 +++ packages/ginny/src/prompts/programmer.ts | 168 ++++++++++++++---- packages/ginny/src/tools/web-get-page.ts | 9 +- packages/ginny/src/tools/write.ts | 28 ++- packages/ginny/src/web-content.ts | 70 +++++++- 19 files changed, 571 insertions(+), 113 deletions(-) diff --git a/packages/gin/src/__tests__/gaps-satisfies.test.ts b/packages/gin/src/__tests__/gaps-satisfies.test.ts index 3615c2d..fe8d8c8 100644 --- a/packages/gin/src/__tests__/gaps-satisfies.test.ts +++ b/packages/gin/src/__tests__/gaps-satisfies.test.ts @@ -4,7 +4,7 @@ import { createRegistry } from '../index'; describe('satisfies enforcement', () => { test('claimed interface not found → error', () => { const r = createRegistry(); - expect(() => r.parse({ name: 'num', satisfies: ['missing-iface'] })).toThrow(/unknown interface/); + expect(() => r.parse({ name: 'num', satisfies: ['missing_iface'] })).toThrow(/unknown interface/); }); test('structural mismatch → error', () => { @@ -14,9 +14,9 @@ describe('satisfies enforcement', () => { props: { fictional: { type: { name: 'any' } } }, }); // Give it a lookup name by wrapping in a named Extension. - const namedIface = r.extend(iface, { name: 'fictional-iface' }); + const namedIface = r.extend(iface, { name: 'fictional_iface' }); r.register(namedIface); - expect(() => r.parse({ name: 'num', satisfies: ['fictional-iface'] })).toThrow(/does not structurally match/); + expect(() => r.parse({ name: 'num', satisfies: ['fictional_iface'] })).toThrow(/does not structurally match/); }); test('structurally satisfying types pass', () => { @@ -27,9 +27,9 @@ describe('satisfies enforcement', () => { eq: { type: { name: 'fn', call: { args: { name: 'obj', props: { other: { type: { name: 'any' } } } }, returns: { name: 'bool' } } } }, }, }); - const named = r.extend(iface, { name: 'has-eq' }); + const named = r.extend(iface, { name: 'has_eq' }); r.register(named); - expect(() => r.parse({ name: 'num', satisfies: ['has-eq'] })).not.toThrow(); + expect(() => r.parse({ name: 'num', satisfies: ['has_eq'] })).not.toThrow(); }); }); @@ -42,9 +42,9 @@ describe('Registry.getTypesFor', () => { toText: { type: { name: 'fn', call: { args: { name: 'obj' }, returns: { name: 'text' } } } }, }, }); - const named = r.extend(iface, { name: 'has-toText' }); + const named = r.extend(iface, { name: 'has_toText' }); r.register(named); - const matches = r.getTypesFor('has-toText'); + const matches = r.getTypesFor('has_toText'); // At minimum: bool, num, any, void, null, not — all declare toText. const names = matches.map((t) => t.name); expect(names).toContain('num'); @@ -53,6 +53,6 @@ describe('Registry.getTypesFor', () => { test('returns empty for unknown interface', () => { const r = createRegistry(); - expect(r.getTypesFor('not-a-real-iface')).toEqual([]); + expect(r.getTypesFor('not_a_real_iface')).toEqual([]); }); }); diff --git a/packages/gin/src/__tests__/registry.test.ts b/packages/gin/src/__tests__/registry.test.ts index 77053fc..d73ff11 100644 --- a/packages/gin/src/__tests__/registry.test.ts +++ b/packages/gin/src/__tests__/registry.test.ts @@ -31,13 +31,13 @@ describe('Registry', () => { // behave permissively (compatible / valid both pass) until the // target gets registered. const r = createRegistry(); - const t = r.parse({ name: 'unknown-type' }); + const t = r.parse({ name: 'unknown_type' }); expect(t.name).toBe('alias'); }); test('parse throws for extends of unknown base', () => { const r = createRegistry(); - expect(() => r.parse({ name: 'x', extends: 'does-not-exist' })).toThrow(); + expect(() => r.parse({ name: 'x', extends: 'does_not_exist' })).toThrow(); }); test('register + lookup roundtrip', () => { diff --git a/packages/gin/src/registry.ts b/packages/gin/src/registry.ts index ad768b2..14911c8 100644 --- a/packages/gin/src/registry.ts +++ b/packages/gin/src/registry.ts @@ -326,6 +326,26 @@ export class Registry implements TypeBuilder, TypeScope { throw new Error(`registry.parse: expected object, got ${typeof json}`); } const def = json as TypeDef; + // Type names must be \w+ (letters, digits, underscore — no + // whitespace, no punctuation). LLM-emitted TypeDefs sometimes + // arrive with leading whitespace or other junk in the name; the + // downstream "claims to satisfy X but does not structurally + // match" error is baffling because the offending whitespace is + // invisible. Reject explicitly here with a precise pointer. + if (typeof def.name !== 'string' || !/^\w+$/.test(def.name)) { + throw new Error(`registry.parse: type 'name' must match /^\\w+$/, got ${JSON.stringify(def.name)}`); + } + if (def.extends !== undefined && (typeof def.extends !== 'string' || !/^\w+$/.test(def.extends))) { + throw new Error(`registry.parse: type 'extends' must match /^\\w+$/, got ${JSON.stringify(def.extends)}`); + } + if (def.satisfies) { + for (const ifaceName of def.satisfies) { + if (typeof ifaceName !== 'string' || !/^\w+$/.test(ifaceName)) { + throw new Error(`registry.parse: 'satisfies' entries must match /^\\w+$/, got ${JSON.stringify(ifaceName)}`); + } + } + } + const result = this.parseInner(def, scope); // `satisfies` claims: verify each against the named interface. diff --git a/packages/gin/src/schemas.ts b/packages/gin/src/schemas.ts index 64a5880..62dfaa2 100644 --- a/packages/gin/src/schemas.ts +++ b/packages/gin/src/schemas.ts @@ -154,11 +154,16 @@ export function extensionSchemaNarrowed( const extendsEnum = allowedNames.length > 0 ? z.enum(allowedNames as [string, ...string[]]) : z.never(); + // Type names are identifiers — letters, digits, underscore. No + // whitespace, no punctuation, no spaces. Catching this at the schema + // layer surfaces a precise zod path ("name: ...") instead of a + // baffling structural-match failure deep inside `registry.parse`. + const identifier = z.string().regex(/^\w+$/, 'must be a /\\w+/ identifier'); return z.object({ - name: z.string(), + name: identifier, extends: extendsEnum, docs: z.string().optional(), - satisfies: z.array(z.string()).optional(), + satisfies: z.array(identifier).optional(), generic: genericSchema(opts).optional(), options: z.record(z.string(), z.any()).optional(), props: z.record(z.string(), propDefSchema(opts)).optional(), diff --git a/packages/gin/src/types/num.ts b/packages/gin/src/types/num.ts index 72e0761..1a0bdc4 100644 --- a/packages/gin/src/types/num.ts +++ b/packages/gin/src/types/num.ts @@ -32,14 +32,14 @@ export class NumType extends Type { return z.object({ name: z.literal('num'), options: z.object({ - min: z.number().optional(), - max: z.number().optional(), - whole: z.boolean().optional(), - minPrecision: z.number().optional(), - maxPrecision: z.number().optional(), - prefix: z.string().optional(), - suffix: z.string().optional(), - }).optional(), + min: z.number().optional().describe('Only set when a real lower bound is part of the spec — e.g. "positive count" → min: 1, "age" → min: 0. Do NOT add `min: 0` to every num just because most numbers happen to be non-negative.'), + max: z.number().optional().describe('Only set when there is an actual upper bound — a percentage capped at 100, a year capped at 9999. Do NOT pick a generic ceiling like 1000/9999 to fill the field.'), + whole: z.boolean().optional().describe('Only set to true when the value is genuinely integral (counts, indices, ids). Leave unset (allow fractions) for measurements, ratios, etc.'), + minPrecision: z.number().optional().describe('Decimal-place floor. Almost never needed; omit unless the spec explicitly requires N decimal places.'), + maxPrecision: z.number().optional().describe('Decimal-place ceiling. Same rule as minPrecision — omit unless explicitly required.'), + prefix: z.string().optional().describe('Display-only prefix (e.g. "$"). Has no effect on validation. Omit unless rendering needs it.'), + suffix: z.string().optional().describe('Display-only suffix (e.g. "%"). Same as prefix — omit unless rendering needs it.'), + }).optional().describe('Omit entirely for ordinary numbers. Only include when the value has a real, named constraint worth enforcing on every parse.'), }).meta({ aid: 'Type_num' }); } diff --git a/packages/gin/src/types/text.ts b/packages/gin/src/types/text.ts index 68ba99f..185ed6c 100644 --- a/packages/gin/src/types/text.ts +++ b/packages/gin/src/types/text.ts @@ -33,11 +33,11 @@ export class TextType extends Type { return z.object({ name: z.literal('text'), options: z.object({ - minLength: z.number().optional(), - maxLength: z.number().optional(), - pattern: z.string().optional(), - flags: z.string().optional(), - }).optional(), + minLength: z.number().optional().describe('Only set when a real lower bound is part of the spec — e.g. "non-empty input" → minLength: 1. Do NOT default to 0.'), + maxLength: z.number().optional().describe('Only set when there is an actual upper bound — a database column width, an API limit, an explicit "no longer than X chars" rule. Do NOT pick a generic ceiling like 100/200/1000 just to fill the field.'), + pattern: z.string().optional().describe('Only set for actual format constraints (UUID, ISO date, slug, etc.). Do NOT use ".*" or other accept-anything patterns — they add zero validation and clutter the schema.'), + flags: z.string().optional().describe('Regex flags (e.g. "i"). Omit unless `pattern` is set AND requires flags.'), + }).optional().describe('Omit entirely for ordinary strings. Only include when the value has a real, named constraint worth enforcing on every parse.'), }).meta({ aid: 'Type_text' }); } diff --git a/packages/ginny/esbuild.config.cjs b/packages/ginny/esbuild.config.cjs index 29873ad..5ca3dc5 100644 --- a/packages/ginny/esbuild.config.cjs +++ b/packages/ginny/esbuild.config.cjs @@ -24,9 +24,34 @@ const esmBanner = { import { createRequire as __createRequire } from 'module'; import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __dirname_func } from 'path'; +import { setMaxListeners as __setMaxListeners } from 'events'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __dirname_func(__filename); const require = __createRequire(import.meta.url); + +// Lift the AbortSignal listener cap before ANY module's top-level code +// runs. \`setMaxListeners(n)\` with no target sets the global default, +// but Node's EventTarget doesn't reliably honour that default across +// versions — and even when it does, that only fixes signals our code +// can see. The cap also fires for AbortSignals created INSIDE the AI +// library / SDKs / built-in fetch, which we never get a handle to. +// +// The bulletproof fix (per cletus: setMaxListeners(Infinity, signal)) +// is to patch \`globalThis.AbortController\` so every signal — ours, +// theirs, fetch's — is uncapped at birth. Banner code runs before any +// import, so SDKs that capture \`globalThis.AbortController\` at module +// init also see the patched constructor. +try { __setMaxListeners(0); } catch { /* runtime mismatch */ } +const __OriginalAbortController = globalThis.AbortController; +class __UncappedAbortController extends __OriginalAbortController { + constructor() { + super(); + try { + __setMaxListeners(Number.POSITIVE_INFINITY, this.signal); + } catch { /* unsupported runtime */ } + } +} +globalThis.AbortController = __UncappedAbortController; `, }; diff --git a/packages/ginny/src/ai.ts b/packages/ginny/src/ai.ts index 109aa5f..b39fa67 100644 --- a/packages/ginny/src/ai.ts +++ b/packages/ginny/src/ai.ts @@ -1,5 +1,5 @@ import { loadConfig } from './config'; -import { logger } from './logger'; +import { logger, genId } from './logger'; import { AI, type Provider } from '@aeye/ai'; import { OpenAIProvider } from '@aeye/openai'; import { OpenRouterProvider } from '@aeye/openrouter'; @@ -70,6 +70,38 @@ const awsChatHooks = { }, }; +/** + * Shared retry-event handlers — every provider that accepts a `retryEvents` + * option uses these so retry attempts (especially 429s) are visible in + * `ginny.log`. Each retry burst gets a 6-char id stamped on every line so a + * single `grep ` recovers the whole sequence: provider, op, attempts, + * timings, and final outcome. + * + * The defaults built into the providers (3 retries, 1s base, exponential + * backoff, jittered, retryable on [0, 429, 500, 503]) handle transient + * rate-limit blips automatically. When the 429 message says "quota" / + * "billing" we annotate the log so you can tell a credit-exhausted error + * from a genuine rate-limit one instantly. + */ +function makeRetryEvents() { + return { + onRetry: (attempt: number, error: Error, delay: number, ctxMeta: { operation: string; provider: string; requestId?: string }) => { + const id = ctxMeta.requestId ?? genId(); + const isQuota = /quota|billing|insufficient/i.test(error.message); + const flavor = isQuota ? 'quota-exhausted (NOT retryable)' : 'transient'; + logger.log(`[${id}] retry attempt=${attempt} provider=${ctxMeta.provider} op=${ctxMeta.operation} flavor=${flavor} delay=${delay}ms err=${error.message}`); + }, + onMaxRetriesExceeded: (attempts: number, lastError: Error, ctxMeta: { operation: string; provider: string; requestId?: string }) => { + const id = ctxMeta.requestId ?? genId(); + logger.log(`[${id}] retry-exhausted attempts=${attempts} provider=${ctxMeta.provider} op=${ctxMeta.operation} err=${lastError.message}`); + }, + onTimeout: (duration: number, ctxMeta: { operation: string; provider: string; requestId?: string }) => { + const id = ctxMeta.requestId ?? genId(); + logger.log(`[${id}] retry-timeout duration=${duration}ms provider=${ctxMeta.provider} op=${ctxMeta.operation}`); + }, + }; +} + async function buildProviders(): Promise<{ providers: Record; enabled: string[]; @@ -79,10 +111,15 @@ async function buildProviders(): Promise<{ const skipped: string[] = []; const providers: Record = {}; + const retryEvents = makeRetryEvents(); + if (process.env['OPENAI_API_KEY']) { providers.openai = new OpenAIProvider({ apiKey: process.env['OPENAI_API_KEY']!, hooks: { chat: openaiChatHooks }, + // Defaults are sane (3 retries, 1s base, expo backoff, retry on + // [0, 429, 500, 503]); we just want visibility into when they fire. + retryEvents, }); enabled.push('openai'); } else { @@ -93,6 +130,7 @@ async function buildProviders(): Promise<{ providers.openrouter = new OpenRouterProvider({ apiKey: process.env['OPENROUTER_API_KEY']!, hooks: { chat: openrouterChatHooks }, + retryEvents, }); enabled.push('openrouter'); } else { diff --git a/packages/ginny/src/event-display.ts b/packages/ginny/src/event-display.ts index 968969b..91cde5f 100644 --- a/packages/ginny/src/event-display.ts +++ b/packages/ginny/src/event-display.ts @@ -11,7 +11,7 @@ * on the args object — the same reference is reused across the * matching toolStart / toolOutput / toolError events). */ -import { logger } from './logger'; +import { logger, genId } from './logger'; const ESC = '\x1b['; const RESET = `${ESC}0m`; @@ -42,6 +42,17 @@ type LastEventKind = 'thinking' | 'text' | 'tool' | null; export class EventDisplay { private toolStarts = new WeakMap(); private last: LastEventKind = null; + /** + * Has the streamed text line been left open (no trailing newline)? + * Set on every `textPartial`, cleared once we write the terminating + * `\n`. Tracked separately from `last` so that consuming the + * "terminate the line" event (`text` / `textComplete` / a tool + * boundary) can reset this independently — otherwise the next event + * would try to write a second newline. + */ + private textLineOpen = false; + /** Latches when we ever write streamed user text — used by `producedText`. */ + private hasProducedText = false; private color: boolean; private thinkingShownThisTurn = false; @@ -54,8 +65,9 @@ export class EventDisplay { } private breakIfText(): void { - if (this.last === 'text') { + if (this.textLineOpen) { process.stdout.write('\n'); + this.textLineOpen = false; } } @@ -87,8 +99,25 @@ export class EventDisplay { } case 'textPartial': { - process.stdout.write(event.content ?? ''); - this.last = 'text'; + const chunk = event.content ?? ''; + if (chunk) { + process.stdout.write(chunk); + this.last = 'text'; + this.textLineOpen = true; + this.hasProducedText = true; + } + break; + } + + case 'text': + case 'textComplete': { + // Streaming for this text segment is done — `text` fires when + // the model finishes its prose for a turn (just before any + // tool calls), `textComplete` fires once at the very end of + // the response. Either way, terminate the streamed line so + // whatever prints next (tool boundary, prompt, etc.) starts + // on its own row instead of butting up against the last word. + this.breakIfText(); break; } @@ -126,9 +155,15 @@ export class EventDisplay { const elapsed = started ? Date.now() - started : 0; // Cap the on-screen error: zod / aggregate errors can run hundreds // of lines and bury the live view. Full text still goes to ginny.log. - const line = `✗ ${event.tool.name} (${elapsed}ms): ${preview(event.error)}`; + // The 6-char id ties the one-liner to the full stack/args + // dumped to ginny.log — grep `` to surface everything. + const id = genId(); + const line = `✗ ${event.tool.name} [${id}] (${elapsed}ms): ${preview(event.error)}`; process.stderr.write(`${this.c(RED, line)}\n`); - logger.log(`✗ ${event.tool.name} (${elapsed}ms): ${event.error}`); + logger.log(`[${id}] tool=${event.tool.name} (${elapsed}ms) error: ${event.error}`); + const stack = (event.error as { stack?: string } | undefined)?.stack; + if (stack) logger.log(`[${id}] stack:\n${stack}`); + try { logger.log(`[${id}] args: ${JSON.stringify(event.args)}`); } catch { /* ignore */ } this.last = 'tool'; break; } @@ -152,8 +187,8 @@ export class EventDisplay { } } - /** Did we ever stream user-visible text? */ + /** Did we ever stream user-visible text during the run? */ get producedText(): boolean { - return this.last === 'text'; + return this.hasProducedText; } } diff --git a/packages/ginny/src/index.ts b/packages/ginny/src/index.ts index 9b8dd3f..4c6a415 100644 --- a/packages/ginny/src/index.ts +++ b/packages/ginny/src/index.ts @@ -1,9 +1,25 @@ #!/usr/bin/env node +// NOTE: AbortSignal listener cap. Lifted in two places, by design: +// +// 1. `esbuild.config.cjs` banner — patches `globalThis.AbortController` +// so every signal created in the bundle (ours, the AI library's, +// SDK internals, fetch's) is uncapped from birth. The banner runs +// before any imported module's top-level code, so SDKs that capture +// `globalThis.AbortController` at module init see the patched ctor. +// +// 2. `setMaxListeners(Infinity)` below — sets the global default for +// future EventTargets/EventEmitters. Belt-and-suspenders: in some +// Node versions EventTarget captures `defaultMaxListeners` at module +// load and doesn't re-read it, which is why (1) is the load-bearing +// fix; this one is the cheap "in case it works" addition. import * as readline from 'readline'; +import events from 'events'; +events.setMaxListeners(Number.POSITIVE_INFINITY); + import type { Message } from '@aeye/core'; import { programmer } from './prompts/programmer'; import { EventDisplay } from './event-display'; -import { logger } from './logger'; +import { logger, genId } from './logger'; import { aiInfo } from './ai'; import { setRuntimeSignal } from './runtime-signal'; @@ -169,6 +185,13 @@ async function runRequest(request: string): Promise { }, ); for await (const event of events) { + // After ESC (or Ctrl+C), stop draining further events. The inner + // streamer's signal listener tears down its in-flight request, + // but the model may still be queuing up follow-up tool calls + // that we shouldn't process — bail here so the run actually + // unwinds back to the prompt instead of grinding through one + // last iteration. + if (abort.signal.aborted) break; // Keep the "ginny is thinking…" spinner alive until the model // actually produces something. `request`/`requestUsage` fire at // the start of an iteration, before any output, so they don't @@ -183,9 +206,12 @@ async function runRequest(request: string): Promise { } if (!display.producedText) { // No text response (e.g. pure tool-only run, or empty answer). - process.stdout.write('(no output)'); + process.stdout.write('(no output)\n'); } - process.stdout.write('\n'); + // No unconditional trailing newline here — when text WAS streamed, + // `EventDisplay` already terminated it on the `text` / + // `textComplete` event. Adding another `\n` here would produce a + // blank line before the next `> ` prompt. } catch (e: unknown) { ensureSpinnerStopped(); const err = e as { message?: string; stack?: string; name?: string }; @@ -194,14 +220,16 @@ async function runRequest(request: string): Promise { } else { // Keep the on-screen error short — zod / aggregate errors can // dump hundreds of lines that bury the prompt. Full message and - // stack go to ginny.log for post-mortem. + // stack go to ginny.log for post-mortem; the 6-char id makes + // both ends of the trail joinable via `grep ginny.log`. + const id = genId(); const raw = err.message ?? String(e); const oneLiner = raw.replace(/\s+/g, ' ').trim(); const short = oneLiner.length > 200 ? `${oneLiner.slice(0, 200)}…` : oneLiner; - console.error(`\nError: ${short}`); - console.error('(see ginny.log for full details)'); - logger.log(`Error: ${raw}`); - if (err.stack) logger.log(err.stack); + console.error(`\nError [${id}]: ${short}`); + console.error(`(see ginny.log — search for ${id})`); + logger.log(`[${id}] runRequest error: ${raw}`); + if (err.stack) logger.log(`[${id}] stack:\n${err.stack}`); } } finally { process.off('SIGINT', onSigint); diff --git a/packages/ginny/src/logger.ts b/packages/ginny/src/logger.ts index ed61005..3b80b96 100644 --- a/packages/ginny/src/logger.ts +++ b/packages/ginny/src/logger.ts @@ -1,5 +1,17 @@ import fs from 'fs'; import path from 'path'; +import crypto from 'crypto'; + +/** + * Generate a short (6 hex chars) "errorable-work" id. Stamp it on a + * pair of log lines — `[id] start` before the work, then either + * `[id] ok` or `[id] error: ` after — and surface the + * id in the user-visible one-liner so a `grep ginny.log` pulls + * up the full context (params, stack, retry attempts, etc.). + */ +export function genId(): string { + return crypto.randomBytes(3).toString('hex'); +} /** * Per-startup logger that writes to `./ginny.log` in the session CWD. diff --git a/packages/ginny/src/natives/fetch.ts b/packages/ginny/src/natives/fetch.ts index 471520e..dec6620 100644 --- a/packages/ginny/src/natives/fetch.ts +++ b/packages/ginny/src/natives/fetch.ts @@ -1,6 +1,12 @@ import type { Registry, Value, Type } from '@aeye/gin'; import { val } from '@aeye/gin'; import { getRuntimeSignal } from '../runtime-signal'; +import { + detectContentType, + contentToMarkdown, + fetchHtmlWithPuppeteer, + BINARY_TYPES, +} from '../web-content'; export function createFetchImpl(registry: Registry) { return async (argsValue: Value): Promise => { @@ -23,32 +29,93 @@ export function createFetchImpl(registry: Registry) { } const outputType = args['output']?.raw as Type | undefined; + // 'markdown' (default): convert HTML/PDF/DOCX/XLSX to markdown, + // wrap JSON/CSV/source in fenced text — the readable form. + // 'raw': return the response body untouched, useful when the + // caller wants the literal HTML/JSON/etc. for downstream parsing. + // Ignored when `output` is set (typed-JSON path always JSON-parses + // the raw body). + const convert = ((args['convert']?.raw as string | null) ?? 'markdown') as 'markdown' | 'raw'; // Forward the entry-point's interrupt signal so an ESC during a // long fetch tears down the underlying HTTP request immediately. const signal = getRuntimeSignal(); - let bodyText = ''; + // Output-typed branch: caller wants the JSON body parsed against a + // specific gin Type (typed obj, list, etc.). Take the raw text, + // JSON-parse it, type-parse it. Markdown conversion would mangle + // the structure here, so it's deliberately skipped. + if (outputType) { + let bodyText = ''; + try { + const resp = await globalThis.fetch(url, { + method, + headers: Object.keys(headersObj).length > 0 ? headersObj : undefined, + body: bodyStr, + signal, + }); + bodyText = await resp.text(); + } catch (e: unknown) { + bodyText = e instanceof Error ? e.message : String(e); + } + try { + const bodyJson = JSON.parse(bodyText); + return outputType.parse(bodyJson); + } catch { + return val(registry.text(), bodyText); + } + } + + // Untyped branch: by default convert whatever the URL returned + // to readable markdown / plaintext so the caller's program (or, + // more often, an `fns.llm` summarization step downstream) gets + // clean text instead of raw HTML / PDF bytes / spreadsheet + // binary. `convert: "raw"` returns the response body untouched + // — useful for downstream parsing or when you want the literal + // HTML/CSS source. try { const resp = await globalThis.fetch(url, { method, - headers: Object.keys(headersObj).length > 0 ? headersObj : undefined, + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; GinBot/1.0)', ...headersObj }, body: bodyStr, signal, }); - bodyText = await resp.text(); - } catch (e: unknown) { - bodyText = e instanceof Error ? e.message : String(e); - } + if (!resp.ok) { + return val(registry.text(), `HTTP ${resp.status} ${resp.statusText}`); + } - if (outputType) { - try { - const bodyJson = JSON.parse(bodyText); - return outputType.parse(bodyJson); - } catch { /* fall through to text */ } - } + if (convert === 'raw') { + // Skip every conversion: return the body as-is. We still go + // through `text()` so the caller gets a string regardless of + // content-type; binary URLs (PDF, etc.) yield best-effort + // utf-8. Use `convert: "markdown"` for those. + const raw = await resp.text(); + return val(registry.text(), raw); + } + + const ct = resp.headers.get('content-type') ?? ''; + const contentType = detectContentType(ct, url); - return val(registry.text(), bodyText); + let raw: string | Buffer; + if (contentType === 'html' && method === 'GET') { + // Re-fetch via headless browser so JS-rendered SPA pages + // aren't returned as empty shells. Puppeteer is GET- + // only — for non-GET HTML (rare) we fall back to the raw + // response body. The signal is plumbed in so an ESC during + // a slow render kills the browser instead of letting it + // hang for the full 30s wall-clock cap. + raw = await fetchHtmlWithPuppeteer(url, signal); + } else if (BINARY_TYPES.has(contentType)) { + raw = Buffer.from(await resp.arrayBuffer()); + } else { + raw = await resp.text(); + } + + const converted = await contentToMarkdown(raw, contentType); + return val(registry.text(), converted); + } catch (e: unknown) { + return val(registry.text(), e instanceof Error ? e.message : String(e)); + } }; } @@ -57,6 +124,10 @@ export function registerFetchType(registry: Registry) { { get: 'get', post: 'post', put: 'put', patch: 'patch', delete: 'delete', head: 'head' }, registry.text(), ); + const convertMode = registry.enum( + { markdown: 'markdown', raw: 'raw' }, + registry.text(), + ); return registry.fn( registry.obj({ @@ -64,9 +135,13 @@ export function registerFetchType(registry: Registry) { method: { type: registry.optional(httpMethod) }, headers: { type: registry.optional(registry.map(registry.text(), registry.text())) }, body: { type: registry.optional(registry.any()) }, + convert: { + type: registry.optional(convertMode), + docs: 'How to deliver the response body when `output` is NOT set. "markdown" (default) auto-converts HTML/PDF/DOCX/XLSX to markdown via headless browser + parsers, and wraps JSON/CSV/source in fenced text — the readable form, ideal for piping into `fns.llm`. "raw" returns the response body untouched as text, for callers that want literal HTML/JSON/etc. for their own parsing. Ignored when `output` is set (typed-JSON path always JSON-parses the raw body).', + }, output: { type: registry.optional(registry.typ(registry.alias('R'))), - docs: 'gin Type to parse the JSON response body through — unifies R in the return type.', + docs: 'gin Type to parse the JSON response body through. ONLY set this for JSON APIs where you know the response shape — the body is JSON.parse-d and parsed against this type. WITHOUT this, fns.fetch returns a single text block (markdown by default, raw if `convert: "raw"`).', }, }), registry.alias('R'), diff --git a/packages/ginny/src/natives/llm.ts b/packages/ginny/src/natives/llm.ts index 86f732e..832e0cd 100644 --- a/packages/ginny/src/natives/llm.ts +++ b/packages/ginny/src/natives/llm.ts @@ -4,6 +4,7 @@ import { val } from '@aeye/gin'; import type { AI } from '@aeye/ai'; import { modelFor } from '../model-selection'; import { getRuntimeSignal } from '../runtime-signal'; +import { logger, genId } from '../logger'; export function createLlmImpl(registry: Registry, ai: AI) { return async (argsValue: Value): Promise => { @@ -57,11 +58,27 @@ export function createLlmImpl(registry: Registry, ai: AI) { ? (result as { value: unknown }).value : result; - if (outputType && finalResult !== undefined) { + if (outputType) { + // Surface a clear error (with a 6-char id pointing at the full + // raw response in ginny.log) when the LLM gave us nothing + // parseable. The previous behaviour fell back to an empty text + // Value, which then surfaced downstream as the cryptic + // "text.parse: expected string, got undefined" against the + // caller's typed slot — much harder to diagnose. + if (finalResult === undefined || finalResult === null) { + const id = genId(); + logger.log(`[${id}] fns.llm returned ${finalResult === null ? 'null' : 'undefined'}; outputType=${outputType.toCode()} prompt=${JSON.stringify(promptText.slice(0, 200))}`); + throw new Error(`fns.llm produced no usable response for output ${outputType.toCode()} [${id}]`); + } try { return outputType.parse(finalResult); - } catch { - return val(registry.text(), JSON.stringify(finalResult)); + } catch (e: unknown) { + const id = genId(); + const raw = typeof finalResult === 'string' + ? finalResult + : (() => { try { return JSON.stringify(finalResult); } catch { return String(finalResult); } })(); + logger.log(`[${id}] fns.llm parse-failure outputType=${outputType.toCode()} raw=${raw}`); + throw new Error(`fns.llm output didn't parse against ${outputType.toCode()} [${id}]: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/packages/ginny/src/progress.ts b/packages/ginny/src/progress.ts index 8a3baeb..5faba6e 100644 --- a/packages/ginny/src/progress.ts +++ b/packages/ginny/src/progress.ts @@ -1,5 +1,5 @@ import { AnyTool, Prompt, PromptEvent, Tuple } from '@aeye/core'; -import { logger } from './logger'; +import { logger, genId } from './logger'; /** * Stream a sub-agent prompt to completion, surfacing per-event progress @@ -107,11 +107,16 @@ export async function runSubagent< const t = toolStarts.get(event.args); const elapsed = t ? Date.now() - t : 0; // Cap the on-screen error to one line — zod / aggregate - // errors easily run 100+ lines and bury the timeline. Full - // text → ginny.log. - const line = ` ✗ ${event.tool.name} (${elapsed}ms): ${preview(event.error)}`; + // errors easily run 100+ lines and bury the timeline. The + // 6-char id is the join key into ginny.log, where the full + // stack + args are recorded. + const id = genId(); + const line = ` ✗ ${event.tool.name} [${id}] (${elapsed}ms): ${preview(event.error)}`; process.stderr.write(`${c(RED, line)}\n`); - logger.log(`✗ ${event.tool.name} (${elapsed}ms): ${event.error}`); + logger.log(`[${id}] tool=${event.tool.name} (${elapsed}ms) error: ${event.error}`); + const stack = (event.error as { stack?: string } | undefined)?.stack; + if (stack) logger.log(`[${id}] stack:\n${stack}`); + try { logger.log(`[${id}] args: ${JSON.stringify(event.args)}`); } catch { /* ignore */ } break; } case 'complete': { diff --git a/packages/ginny/src/prompts/designer.ts b/packages/ginny/src/prompts/designer.ts index 88afca0..229ac37 100644 --- a/packages/ginny/src/prompts/designer.ts +++ b/packages/ginny/src/prompts/designer.ts @@ -535,6 +535,27 @@ caller it's incompatible OR \`create_new_fn\` under a different name. Use \`create_new_fn\` for net-new functionality. +## Sequencing parallel requests + +When a single request asks for multiple fns and one composes the +others (e.g. \`fetchAndSummarize\` calls \`fetchContent\` and +\`summarizeContent\`), you MUST issue \`create_new_fn\` calls +SEQUENTIALLY — one at a time — with the dependencies first, the +composer last. Independent fns can be created in any order. + +Why: tool calls in a single LLM round run in parallel. If you queue +\`fetchContent\`, \`summarizeContent\`, AND \`fetchAndSummarize\` in +the same round, the inner programmer authoring \`fetchAndSummarize\` +sees neither dependency loaded — it has to re-derive the fetch + llm +logic inline, which is exactly the duplication this designer is +supposed to prevent. So: + + Round 1: \`create_new_fn\` for \`fetchContent\`. Wait for the + result. + Round 2: \`create_new_fn\` for \`summarizeContent\`. Wait. + Round 3: \`create_new_fn\` for \`fetchAndSummarize\` — which can + now reference both. + Request: {{description}}`, input: (input: { description: string }) => ({ description: input.description }), tools: [searchFns, getFn, printFn, createNewFn, editFn, ask], diff --git a/packages/ginny/src/prompts/programmer.ts b/packages/ginny/src/prompts/programmer.ts index d1b007e..0810fc3 100644 --- a/packages/ginny/src/prompts/programmer.ts +++ b/packages/ginny/src/prompts/programmer.ts @@ -323,6 +323,31 @@ You orchestrate four specialist sub-agents on demand: - **dba** — curates the \`vars.*\` catalog (\`find_or_create_vars\`) - **researcher** — answers factual questions from the web (\`research\`) +## NEVER print gin code in your prose + +Gin programs are JSON expression trees. They are TOOL INPUT — the +\`write\` tool takes them as the \`program\` arg and renders them back +as readable code for the user. They are NOT readable as a chat +response, and the user can't run them by copying. + +**Hard rule**: do not write a JSON ExprDef, a fenced \`json\` block +containing one, a "here's the program:" preamble followed by JSON, or +a TypeScript-pseudocode rendering of one in your prose response. +Programs always go through the \`write\` tool. If you find yourself +about to type \`{ "kind": "block"\` or \`const x: text = ...\` into +your text reply, stop — that's a \`write\` call. + +The same applies to type definitions, function definitions, and var +shapes — \`find_or_create_types\`, \`find_or_create_functions\`, and +\`find_or_create_vars\` are how those reach the user. Plain-prose +explanations of WHAT a function does are fine and expected; the +DEFINITION of it goes through a tool. + +This is the single most common failure mode. The user sees the JSON +in chat, can't do anything with it, and has to ask you to re-run the +work through the tools. Skip the misstep — call the tool the first +time. + ## How to respond Three modes — pick by request shape, not by guess: @@ -411,8 +436,8 @@ ${EXPR_KINDS} ${PATH_EXPLANATION} ## Globals always available -- \`fns.fetch({ url, method?, headers?, body?, output?: typ }): R\` — HTTP fetch. -- \`fns.llm({ prompt, tools?, output?: typ }): R\` — LLM call. R is constrained to text (free-form replies) or obj (structured outputs); it does NOT default to anything. Choose one and bind it on the call site (see "Generic bindings" below). +- \`fns.fetch({ url, method?, headers?, body?, convert?: "markdown" | "raw", output?: typ }): R\` — HTTP fetch. Three modes: (1) WITHOUT \`output\`, default \`convert: "markdown"\` auto-converts HTML (incl. JS-rendered SPAs via headless browser) / PDF / DOCX / XLSX to markdown and wraps JSON / CSV / source in fenced text — a single readable text block, ideal for \`fns.llm\` summarization. (2) WITHOUT \`output\` and \`convert: "raw"\`, the response body is returned untouched (literal HTML/JSON/etc. for your own parsing). (3) WITH \`output\` set to obj / list / etc., the body is JSON-parsed and type-parsed against \`output\` — \`convert\` is ignored. +- \`fns.llm({ prompt, tools?, output?: typ }): R\` — LLM call. R has no constraint — \`text\` produces a plain-string reply (preferred for simple answers), \`obj\` produces a structured reply via the OpenAI structured-output channel, and other types (enum, num, bool, list, tuple) are auto-wrapped over the wire and unwrapped before parse so callers see the inner value. Bind R explicitly via the call-site \`generic\` (see "Generic bindings" below). - \`fns.log({ message: any }): void\` — print a runtime message to the user (stderr). Use for progress narration, intermediate values, debug breadcrumbs. Distinct from the program's return value. - \`fns.ask({ title: text, details: text, output?: typ }): optional\` — pause execution and prompt the user. With \`output\` set the consumer walks the user through any complex shape (obj fields, list items, choices, optionals). Returns \`null\` (\`optional\`) on cancel — handle that explicitly. - \`vars.*\` — named typed values, persisted on disk. @@ -446,14 +471,50 @@ CallStep alongside \`args\`: Constraint-violating bindings are rejected at the call site: -- \`fns.llm\` with \`generic: { R: { name: "num" } }\` → ERROR - (\`num\` doesn't satisfy \`text | obj\`). -- \`fns.llm\` with \`generic: { R: { name: "text" } }\` → OK. -- \`fns.llm\` with \`generic: { R: } }\` → OK. +- \`fns.llm\` with \`generic: { R: { name: "text" } }\` → plain-string reply. +- \`fns.llm\` with \`generic: { R: } }\` → structured reply. +- \`fns.llm\` with \`generic: { R: { name: "enum", ... } }\` → auto-wrapped on the wire, returned as the unwrapped enum value. -The \`\` form (fetch, ask) means "no constraint" — any binding +The \`\` form (fetch, llm, ask) means "no constraint" — any binding is accepted. +## DON'T over-specify type options on basic types + +When picking a type for a parameter, return, var, or fns.llm/fns.ask +output, default to the BARE type — \`text\`, \`num\`, \`bool\`. Only add +\`options\` (minLength, maxLength, pattern, min, max, whole, …) when +there is a REAL named constraint in the spec. + +Bad — fills options with no actual constraint: +\`\`\`json +// Don't do this. minLength=0 is the default, maxLength=200 is arbitrary, +// pattern=".*" matches anything. All three add nothing but noise. +{ "name": "text", "options": { "minLength": 0, "maxLength": 200, "pattern": ".*" } } +\`\`\` + +Good — bare type: +\`\`\`json +{ "name": "text" } +\`\`\` + +Good — options when there's a real constraint: +\`\`\`json +// "non-empty input" → minLength: 1. +// "API key (32 hex chars)" → pattern: "^[0-9a-f]{32}$". +{ "name": "text", "options": { "minLength": 1 } } +{ "name": "text", "options": { "pattern": "^[0-9a-f]{32}$" } } +\`\`\` + +Same rule for \`num\`: don't add \`min: 0\` to every num because most +numbers happen to be non-negative; only set it when "must be ≥ 0" is +part of the spec. Don't set \`whole: true\` on measurements; only on +counts / indices / ids. + +Why this matters: every option adds runtime validation. An incidental +\`maxLength: 200\` on an LLM output type rejects valid 201-char +responses; an incidental \`whole: true\` rejects fractional results +that are otherwise correct. Constraints rot fast — keep them honest. + ## Writing prompt-friendly types for \`fns.ask\` The ask consumer uses each (sub)type's \`docs\` field as the user-facing @@ -510,31 +571,74 @@ discover. Pick the type up front: Do NOT probe an untyped llm call just to see what it says — decide the shape first, then invoke with \`output\` set. -## \`fns.fetch\` — discover the shape when you don't know it - -Unlike llm, a fetch response comes from a third-party server — you -often don't know the JSON structure up front. Use this flow: - -1. **Probe, no output.** \`write\` a program that fetches WITHOUT - \`output:\`, returning the raw text body. Then \`test()\`. The test - result shows the actual JSON payload. -2. **If one sample isn't enough** — optional keys that only appear - under certain conditions, discriminated enum values you haven't - seen all of, paged endpoints, etc. — call \`research\` (when - available) to look up the API's published response schema. One - sample doesn't decide \`optional\` / \`enum<...>\`; the docs do. -3. **Declare a matching type.** Use \`find_or_create_types\` to define - an obj/list shape that mirrors the JSON, or reuse an existing - compatible type. Unknown-maybe-present fields → \`optional\`. Open- - ended strings → \`text\`. Discriminated value sets → \`enum<...>\` - (only when exhaustive). -4. **Re-write typed.** Replace the fetch with \`output: \`. - \`test()\` again to confirm parsing succeeds against real data. The - rest of your program can now access fields directly. -5. **\`finish()\`** once the typed version tests green. - -Skip steps 1–2 only when the response shape is already clear from the -user's request or obvious from a well-known API you recognize. +## \`fns.fetch\` — three distinct modes + +### Mode A: unstructured content, converted (default) + +For webpages, articles, PDFs, docs, spreadsheets — anything you'd +want to read or summarize, not query as JSON — call \`fns.fetch\` +WITHOUT \`output\`. With the default \`convert: "markdown"\` the +native automatically: + +- Renders HTML (including JS-heavy SPAs) via a headless browser, then + converts to **markdown**. +- Extracts text from PDFs (pdf-parse), DOCX (mammoth), XLSX (xlsx) + and converts to markdown. +- Wraps JSON / CSV / source code in fenced text. +- Returns plain text / markdown / XML as-is. + +The return is a single ready-to-use \`text\` Value. You **do not** +need to write helper functions to "extract text from HTML", "remove +\`