From 9f3ccccd6d71f224baa5163ac45ebb20906903b2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 25 Sep 2026 16:33:31 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Derive=20route=20state?= =?UTF-8?q?=20from=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/insights.md | 7 +- lib/argument.ts | 6 +- lib/command.ts | 2 - lib/option.ts | 6 +- lib/param.ts | 19 +++-- lib/pipeline.ts | 172 +++++++++++++++++++++-------------------- lib/route.ts | 2 - lib/toggle.ts | 6 +- lib/types.ts | 179 ++++++++++++++++++++++++------------------- test/dynamic.test.ts | 61 ++++++++++++--- test/fold.test.ts | 2 - test/parse.test.ts | 4 - 12 files changed, 265 insertions(+), 201 deletions(-) diff --git a/docs/insights.md b/docs/insights.md index 54291e0..1c9eaf7 100644 --- a/docs/insights.md +++ b/docs/insights.md @@ -90,6 +90,9 @@ The detailed phase-binding design is recorded in [Binding](./binding.md). - The resolved route has a directly typed `model`. - The result also carries path-addressed `models` for every matched route. +- A route's phase tuple is the single source of truth for both its aggregate + model and its children; public helpers derive those views instead of caching + them as additional route type parameters. - Route identity and result projection are separate concerns. - Namespacing is a mounting/address operation, not something plugin authors repeat locally. @@ -116,7 +119,9 @@ The detailed phase-binding design is recorded in [Binding](./binding.md). - A failed requirement never invokes the resolver. - `RequirementsOf` preserves requirement order; `RequirementOf` is its head. -- `ContinuationOf` removes the current phase. +- `ContinuationOf` settles the current unresolved phase and retains it as + immutable type history, so later continuations can derive the aggregate model + without a separate model cache. - The static result type says whether parsing yields another increment or an intent; callers should not need a runtime `done` check. - Help and version may require resolving earlier phases when those phases can diff --git a/lib/argument.ts b/lib/argument.ts index 6037b1e..18c2d54 100644 --- a/lib/argument.ts +++ b/lib/argument.ts @@ -1,9 +1,9 @@ -import { type Param, param } from "./param.ts"; +import { type Param, param, type ParamModel } from "./param.ts"; import { brand, type Check, type Fold, - type ParamElement, + type ModelElement, type Unary, } from "./pipeline.ts"; import type { CLIRead, ReadCLI } from "./read.ts"; @@ -45,7 +45,7 @@ export function argument< type ValueOf

= P extends Param ? T : never; type ElementOf = P extends Param - ? ParamElement> + ? ModelElement>> : never; function positional

>(param: P): P { diff --git a/lib/command.ts b/lib/command.ts index 520e595..4da8fe4 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -5,8 +5,6 @@ import type { Definition, Done, Route } from "./types.ts"; export type CommandZero = Route< N, "help" | "execute", - {}, - [], [Done<{}, []>] >; diff --git a/lib/option.ts b/lib/option.ts index 2b52a7a..a4a9f34 100644 --- a/lib/option.ts +++ b/lib/option.ts @@ -1,10 +1,10 @@ -import { type Param, param } from "./param.ts"; +import { type Param, param, type ParamModel } from "./param.ts"; import { dasherize } from "./dasherize.ts"; import { brand, type Check, type Fold, - type ParamElement, + type ModelElement, type Unary, } from "./pipeline.ts"; import { cli } from "./read.ts"; @@ -44,5 +44,5 @@ export function option< type ValueOf

= P extends Param ? T : never; type ElementOf = P extends Param - ? ParamElement> + ? ModelElement>> : never; diff --git a/lib/param.ts b/lib/param.ts index 59d75b4..60c3383 100644 --- a/lib/param.ts +++ b/lib/param.ts @@ -3,6 +3,7 @@ import { type Check, type Fold, mark, + type ModelPatch, type Transform, type TransformElement, type Unary, @@ -17,6 +18,8 @@ export interface Param extends Definition { env?: string; } +export type ParamModel = ModelPatch<{ [P in K]: V }>; + export function param< const K extends string, const E extends readonly Unary[], @@ -51,17 +54,19 @@ export function param< export function schema( schema: S, -): TransformElement> { - return mark>((param: Param) => ({ - ...param, - schema, - })); +): TransformElement>> { + return mark>>( + (param: Param) => ({ + ...param, + schema, + }), + ); } -interface SchemaTransform extends Transform { +interface SchemaTransform extends Transform { readonly input: Param; readonly output: this["input"] extends Param - ? Param> + ? Param : never; } diff --git a/lib/pipeline.ts b/lib/pipeline.ts index cf45dcd..a74d23e 100644 --- a/lib/pipeline.ts +++ b/lib/pipeline.ts @@ -1,15 +1,11 @@ import type { - AddField, - AddParamToLast, AddRoutesToLast, AnyPhase, AnyPhases, AnyRoute, - ChildrenOf, Done, Method, MethodsOf, - ModelOf, Next, Phase, Route, @@ -34,12 +30,42 @@ export type ApplyTransform = ( F & { readonly input: S } )["output"]; +export interface ModelTransform extends Transform { + readonly input: object; + readonly output: object; +} + +// We don’t need ModelPatch semantically. It is only a TypeScript +// performance marker. Additive transforms expose their fields only so +// static folds can batch them. ModelPatch exposes that additive field +// set so the fold can combine twenty parameter operations into one +// model update. Without it, every parameter must be applied as +// another higher-kinded transform. Even with five-item +// chunks and materializing after every step—and the 100-option tests +// hit TS2589: type instantiation is excessively deep. +export interface ModelPatch extends ModelTransform { + readonly fields: Fields; + readonly input: object; + readonly output: ApplyModelPatch; +} + +export type ApplyModelPatch< + Model extends object, + Fields extends object, +> = SimplifyModel & Fields>; + export interface TransformElement { readonly [operation]: Custom; (value: S): ApplyTransform; } +export interface ModelElement { + readonly [operation]: TransformModel; + + (route: R): Apply>; +} + export interface IdentityElement { readonly [operation]: Identity; @@ -52,28 +78,8 @@ export interface MethodElement { < const N extends string, const M extends Method, - const T extends object, - const C extends readonly AnyRoute[], const P extends AnyPhases, - >(route: Route): Route; -} - -export interface ParamElement { - readonly [operation]: AddParam; - - < - const N extends string, - const M extends Method, - const T extends object, - const C extends readonly AnyRoute[], - const P extends AnyPhases, - >(route: Route): Route< - N, - M, - AddField, - C, - AddParamToLast - >; + >(route: Route): Route; } export interface RoutesElement { @@ -82,14 +88,10 @@ export interface RoutesElement { < const N extends string, const M extends Method, - const T extends object, - const C extends readonly AnyRoute[], const P extends AnyPhases, - >(route: Route): Route< + >(route: Route): Route< N, M, - T, - readonly [...C, ...Added], AddRoutesToLast >; } @@ -121,8 +123,6 @@ export interface DynamicElement { export type Seed = Route< R["name"], MethodsOf, - ModelOf, - ChildrenOf, readonly [Phase<{}, [], never>] >; @@ -162,15 +162,11 @@ export type Materialize = AnyRoute extends S ? S : S extends Route< infer N, infer M, - infer Model, - infer Children, infer Phases > ? WithExtras< Route< N, M, - { [K in keyof Model]: Model[K] }, - Children, { [K in keyof Phases]: Phases[K] extends AnyPhase ? MaterializePhase @@ -205,7 +201,7 @@ declare const operation: unique symbol; type Delta = | Identity | AddMethod - | AddParam + | TransformModel | AddRoutes | Batch | Dynamic @@ -214,7 +210,7 @@ type Delta = type StaticDelta = | Identity | AddMethod - | AddParam + | TransformModel> | AddRoutes; interface Identity { @@ -227,10 +223,9 @@ interface AddMethod { readonly method: M; } -interface AddParam { - readonly type: "param"; - readonly key: K; - readonly value: V; +interface TransformModel { + readonly type: "model"; + readonly transform: F; } interface AddRoutes { @@ -259,19 +254,14 @@ type Apply = Delta extends D ? Conservative : D extends AddMethod ? S extends AnyRoute ? Route< S["name"], MethodsOf | M, - ModelOf, - ChildrenOf, S["phases"] > : never - : D extends AddParam - ? S extends AnyRoute ? WithParams + : D extends TransformModel ? S extends AnyRoute ? WithModel : never : D extends AddRoutes ? S extends AnyRoute ? Route< S["name"], MethodsOf, - ModelOf, - readonly [...ChildrenOf, ...C], AddRoutesToLast > : never @@ -281,8 +271,6 @@ type Apply = Delta extends D ? Conservative ? Apply, DeltaOf> extends infer After extends AnyRoute ? Route< After["name"], MethodsOf, - ModelOf, - ChildrenOf, ConjoinPhases > : never @@ -332,7 +320,7 @@ type InputOfPipeline = E extends type InputOfDelta = D extends Identity ? Input : D extends | AddMethod - | AddParam + | TransformModel | AddRoutes< readonly AnyRoute[] > @@ -341,6 +329,7 @@ type InputOfDelta = D extends Identity ? Input : D extends Custom ? F["input"] : never; +// Bound each scan; Fold resumes with E, so this is not an element limit. type TakeStatic< E extends readonly AnyPipelineElement[], Fields extends readonly object[] = readonly [], @@ -366,9 +355,10 @@ type TakeStatic< Methods | M, readonly [...Count, unknown] > - : DeltaOf extends AddParam ? TakeStatic< + : DeltaOf extends TransformModel> + ? TakeStatic< Tail, - readonly [...Fields, { [P in K]: V }], + readonly [...Fields, Added], Routes, Methods, readonly [...Count, unknown] @@ -383,7 +373,6 @@ type TakeStatic< : readonly [MergeFields, Routes, Methods, E] : readonly [MergeFields, Routes, Methods, E]; -// Collect fields before applying them so each static chunk builds the model once. type MergeFields = { [K in FieldKeys]: FieldValue; }; @@ -403,6 +392,19 @@ type FieldValue< ] ? K extends keyof Last ? Last[K] : FieldValue : never; +type WithModel< + R extends AnyRoute, + F extends ModelTransform, +> = ApplyModel>, F> extends infer Model + ? [Model] extends [never] ? never + : Model extends object ? Route< + R["name"], + MethodsOf, + ReplaceLastModel> + > + : never + : never; + type WithStatic< R extends AnyRoute, Fields extends object, @@ -411,9 +413,6 @@ type WithStatic< > = Route< R["name"], MethodsOf | Methods, - keyof Fields extends never ? ModelOf : Merge, Fields>, - Routes extends readonly [] ? ChildrenOf - : readonly [...ChildrenOf, ...Routes], AddStaticToLast >; @@ -426,37 +425,54 @@ type AddStaticToLast< : Routes extends readonly [] ? AddFieldsToLast : AddRoutesToLast, Routes>; -type WithParams = Route< - R["name"], - MethodsOf, - Merge, Fields>, - ChildrenOf, - AddFieldsToLast ->; - type AddFieldsToLast< P extends AnyPhases, Fields extends object, +> = ReplaceLastModel>, Fields>>; + +type AddFields = ApplyModelPatch< + Model, + Fields +>; + +type ReplaceLastModel< + P extends AnyPhases, + Model extends object, > = P extends readonly [infer Only extends AnyPhase] - ? readonly [AddFields] + ? readonly [WithPhaseModel] : P extends readonly [ infer First extends AnyPhase, ...infer Middle extends AnyPhase[], AnyPhase, - ] ? readonly [First, ...Middle, AddFields, Fields>] + ] ? readonly [First, ...Middle, WithPhaseModel, Model>] : never; -type AddFields

= P extends Next< - infer Model, +type WithPhaseModel

= P extends Next< + infer _Current, infer Routes, infer Requirement -> ? Next, Routes, Requirement> - : P extends Done ? Done< - Merge, - Routes - > +> ? Next + : P extends Done ? Done : never; +type PhaseModel

= P extends Next< + infer Model, + readonly AnyRoute[], + infer _Requirement +> ? Model + : P extends Done ? Model + : never; + +type ApplyModel< + Model extends object, + F extends ModelTransform, +> = Model extends F["input"] ? ApplyTransform + : never; + +type MaterializeModel = SimplifyModel; + +type SimplifyModel = { [K in keyof Model]: Model[K] }; + type Last

= P extends readonly [ ...AnyPhase[], infer Tail extends AnyPhase, @@ -499,12 +515,6 @@ type WithRequirement< ? Phase : never; -type Merge = Simplify< - Omit & B ->; - -type Simplify = { [K in keyof T]: T[K] }; - type MaterializePhase

= P extends Next< infer Model, infer Routes, @@ -523,8 +533,6 @@ type MaterializePhase

= P extends Next< type RouteKeys = keyof Route< string, Method, - object, - readonly AnyRoute[], AnyPhases >; diff --git a/lib/route.ts b/lib/route.ts index e6be542..717b255 100644 --- a/lib/route.ts +++ b/lib/route.ts @@ -13,8 +13,6 @@ import type { AnyRoute, Definition, Done, Route } from "./types.ts"; export type RouteZero = Route< N, "help", - {}, - [], [Done<{}, []>] >; diff --git a/lib/toggle.ts b/lib/toggle.ts index 10b07a1..20fa65a 100644 --- a/lib/toggle.ts +++ b/lib/toggle.ts @@ -1,11 +1,11 @@ import { boolean as decode } from "./decode.ts"; import { dasherize } from "./dasherize.ts"; -import { type Param, param, schema } from "./param.ts"; +import { type Param, param, type ParamModel, schema } from "./param.ts"; import { brand, type Check, type Fold, - type ParamElement, + type ModelElement, type Unary, } from "./pipeline.ts"; import type { CLIRead, ReadCLI } from "./read.ts"; @@ -50,7 +50,7 @@ export function toggle< type ValueOf

= P extends Param ? T : never; type ElementOf = P extends Param - ? ParamElement> + ? ModelElement>> : never; function binding( diff --git a/lib/types.ts b/lib/types.ts index f04f3e2..cabde19 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -20,15 +20,11 @@ export interface Definition { export interface Route< N extends string, M extends Method, - T extends object, - C extends readonly AnyRoute[], P extends AnyPhases, > extends Definition { readonly methods: readonly M[]; readonly version?: string; readonly phases: P; - readonly model?: T; - readonly children?: C; } export type Parse = Outcome>; @@ -45,7 +41,8 @@ export type Next< Routes extends readonly AnyRoute[], T, > = { - readonly params: Params; + readonly [phaseModel]?: Model; + readonly params: Params; readonly routes: Routes; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; @@ -58,15 +55,14 @@ export type Done< Model extends object, Routes extends readonly AnyRoute[], > = { - readonly params: Params; + readonly [phaseModel]?: Model; + readonly params: Params; readonly routes: Routes; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; }; -export type Params = { - [K in keyof Model]: K extends string ? Param : never; -}; +export type Params = Readonly>>; export interface ParseIncrement< R extends AnyRoute, @@ -82,41 +78,24 @@ export interface ParseIncrement< ): Outcome, P, Models>>; } -export type ContinuationOf = R extends Route< - string, - Method, - object, - readonly AnyRoute[], - readonly [ - AnyPhase, - infer Next extends AnyPhase, - ...infer Tail extends AnyPhase[], - ] -> ? Route< - R["name"], - R["methods"][number], - ModelOf, - ChildrenOf, - readonly [Next, ...Tail] - > - : never; +export type ContinuationOf = Route< + R["name"], + MethodsOf, + SettleNext +>; -export type ModelOf = R extends Route< - string, - Method, - infer T, - readonly AnyRoute[], - readonly [AnyPhase, ...readonly AnyPhase[]] -> ? T - : never; +export type ModelOf< + R extends AnyRoute, + P extends RoutePath = "/", +> = ModelOfRoute>; -export type ChildrenOf = R extends - Route ? Children - : never; +export type ChildrenOf = RoutesIn; export type RequirementsOf = RequirementsIn; -export type RequirementOf = RequirementIn; +export type RequirementOf = RequirementInPhases< + R["phases"] +>; export interface AnyRoute extends Definition { readonly methods: readonly Method[]; @@ -125,7 +104,8 @@ export interface AnyRoute extends Definition { } export interface AnyPhase { - readonly params: Params; + readonly [phaseModel]?: object; + readonly params: Params; readonly routes: readonly AnyRoute[]; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; @@ -232,6 +212,77 @@ export interface UnprocessableContent extends Failure<"unprocessable-content"> { readonly issues: Issue[]; } +declare const phaseModel: unique symbol; + +type ModelOfRoute = ModelsIn extends + infer Model extends object ? { [K in keyof Model]: Model[K] } + : never; + +type ModelsIn

= P extends readonly [ + infer Head extends AnyPhase, + ...infer Tail extends readonly AnyPhase[], +] ? Omit, ModelKeys> & ModelsIn + : {}; + +type ModelKeys

= P extends readonly [ + infer Head extends AnyPhase, + ...infer Tail extends readonly AnyPhase[], +] ? keyof PhaseModelOf | ModelKeys + : never; + +type PhaseModelOf

= P extends { + readonly [phaseModel]?: infer Model extends object; +} ? Model + : never; + +type RoutesIn

= P extends readonly [ + infer Head extends AnyPhase, + ...infer Tail extends readonly AnyPhase[], +] ? readonly [...Head["routes"], ...RoutesIn] + : readonly []; + +type RouteAt< + R extends AnyRoute, + P extends RoutePath, +> = P extends "/" ? R + : P extends `/${infer Head}/${infer Tail}` + ? FindRoute, Head> extends infer Child extends AnyRoute + ? RouteAt + : never + : P extends `/${infer Name}` ? FindRoute, Name> + : never; + +type FindRoute< + C extends readonly AnyRoute[], + N extends string, +> = C extends readonly [ + infer Head extends AnyRoute, + ...infer Tail extends readonly AnyRoute[], +] ? Head["name"] extends N ? Head + : FindRoute + : never; + +type SettleNext

= P extends readonly [ + infer Head extends AnyPhase, + ...infer Tail extends readonly AnyPhase[], +] + ? Head extends Next + ? readonly [Done, ...Tail] + : Tail extends AnyPhases ? readonly [Head, ...SettleNext] + : P + : never; + +type NextModelIn

= P extends readonly [ + infer Head extends AnyPhase, + ...infer Tail extends readonly AnyPhase[], +] ? Head extends Next< + infer Model, + readonly AnyRoute[], + infer _Requirement + > ? Model + : NextModelIn + : never; + type RequirementIn

= P extends { readonly resolver: ( requirement: infer Requirement, @@ -239,13 +290,15 @@ type RequirementIn

= P extends { } ? Requirement : never; -type IncrementModelOf = R["phases"][0] extends Next< - infer Model, - readonly AnyRoute[], - infer Requirement -> ? Model +type RequirementInPhases

= P extends readonly [ + infer Head extends AnyPhase, + ...infer Tail extends readonly AnyPhase[], +] ? [RequirementIn] extends [never] ? RequirementInPhases + : RequirementIn : never; +type IncrementModelOf = NextModelIn; + type ParseAt< R extends AnyRoute, P extends RoutePath, @@ -292,7 +345,7 @@ type ParseChildren< type RequirementsIn

= P extends readonly [ infer Head extends AnyPhase, ...infer Tail extends AnyPhase[], -] ? [RequirementIn] extends [never] ? readonly [] +] ? [RequirementIn] extends [never] ? RequirementsIn : readonly [RequirementIn, ...RequirementsIn] : readonly []; @@ -317,34 +370,6 @@ type Append< > = A extends "/" ? `/${N}` : `${A}/${N}`; -type AddParam< - P extends AnyPhase, - K extends string, - V, -> = P extends Next< - infer Model, - infer Routes, - infer Requirement -> ? Next< - AddField, - Routes, - Requirement - > - : P extends Done ? Done< - AddField, - Routes - > - : never; - -export type AddParamToLast< - P extends AnyPhases, - K extends string, - V, -> = ReplaceLast< - P, - AddParam, K, V> ->; - export type AddRoutesToLast< P extends AnyPhases, C extends readonly AnyRoute[], @@ -388,12 +413,6 @@ type AddRoutes< > : never; -export type AddField = Simplify< - Omit & { [P in K]: V } ->; - -type Simplify = { [P in keyof T]: T[P] }; - type IntentsAt< R extends AnyRoute, P extends RoutePath, diff --git a/test/dynamic.test.ts b/test/dynamic.test.ts index d08741c..4337b09 100644 --- a/test/dynamic.test.ts +++ b/test/dynamic.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { type } from "arktype"; import { command } from "../lib/command.ts"; +import { checkpoint } from "../lib/checkpoint.ts"; import { name } from "../lib/definition.ts"; import { type ConjoinPhases, @@ -20,6 +21,8 @@ import type { ChildrenOf, ContinuationOf, Done, + Execute, + Help, MethodsOf, ModelOf, Next, @@ -29,29 +32,26 @@ import type { RequirementsOf, Route, RoutePath, + Version, } from "../lib/types.ts"; import { toggle } from "../lib/toggle.ts"; describe("dynamic()", () => { - it("starts its extension with aggregate state and a fresh phase", () => { + it("starts its extension with a fresh phase", () => { type Child = Route< "serve", "help" | "execute", - { port: number }, - [], readonly [Done<{ port: number }, []>] >; type Before = Route< "simulacrum", "help" | "execute", - { config: string }, - readonly [Child], readonly [Done<{ config: string }, readonly [Child]>] >; type Next = Seed; - expectType, { config: string }>>(true); - expectType, readonly [Child]>>(true); + expectType, {}>>(true); + expectType, readonly []>>(true); expectType< Equal, readonly [Done<{}, []>]> >(true); @@ -61,8 +61,6 @@ describe("dynamic()", () => { type A = Route< "simulacrum", "help" | "execute", - { a: string; b: number }, - [], readonly [ Next<{ a: string }, [], Config>, Done<{ b: number }, []>, @@ -71,8 +69,6 @@ describe("dynamic()", () => { type B = Route< "simulacrum", "help" | "execute", - { a: string; b: number; c: boolean; d: string }, - [], readonly [ Next<{ c: boolean }, [], Plugins>, Done<{ d: string }, []>, @@ -143,7 +139,10 @@ describe("dynamic()", () => { expectType< Equal< PhasesOf, - readonly [Done<{ port: number; domain: string }, []>] + readonly [ + Done<{ config: string }, []>, + Done<{ port: number; domain: string }, []>, + ] > >(true); }); @@ -189,6 +188,44 @@ describe("dynamic()", () => { >(true); }); + it("derives path models and final intents from the phase tuple", () => { + let app = route( + name("simulacrum"), + version("1.2.0"), + option(name("config"), schema(type("string"))), + checkpoint(), + dynamic(() => routes(command(name("dyn")))), + option(name("delay"), schema(type("number"))), + routes( + command( + name("serve"), + option(name("port"), schema(type("number"))), + ), + ), + ); + + type Final = Extract< + Parse>>, + { readonly ok: true } + >; + type Root = { config: string; delay: number }; + + expectType, Root>>(true); + expectType, {}>>(true); + expectType, { port: number }>>(true); + expectType< + Equal< + Final, + | Help<"/"> + | Version<"/"> + | Help<"/dyn"> + | Execute<"/dyn", { "/": Root; "/dyn": {} }> + | Help<"/serve"> + | Execute<"/serve", { "/": Root; "/serve": { port: number } }> + > + >(true); + }); + it("rejects a resolver that does not return an extension", () => { check(() => { // @ts-expect-error a command is a definition, not a route extension. diff --git a/test/fold.test.ts b/test/fold.test.ts index 3327e7e..323d269 100644 --- a/test/fold.test.ts +++ b/test/fold.test.ts @@ -563,8 +563,6 @@ describe("pipeline fold", () => { type Base = Route< "base", "help", - { readonly existing?: number }, - [], readonly [Done<{ readonly existing?: number }, []>] >; diff --git a/test/parse.test.ts b/test/parse.test.ts index 21a0338..c596b3f 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -409,15 +409,11 @@ describe("parse()", () => { type Plain = Route< "simulacrum", "help", - Empty, - [], readonly [Done] >; type Versioned = Route< "simulacrum", "help" | "version", - Empty, - [], readonly [Done] >; From 1afb581358b2bee980c6568607e5ec3e479c583a Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 26 Sep 2026 11:22:20 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Separate=20phase=20bin?= =?UTF-8?q?ding=20from=20model=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/argument.ts | 15 ++++++++++++--- lib/bind.ts | 18 ++++++++++++++---- lib/command.ts | 5 ++++- lib/dynamic.ts | 5 ++++- lib/option.ts | 15 ++++++++++++--- lib/parse.ts | 25 ++++++++++++++++++------- lib/print.ts | 2 +- lib/route.ts | 5 ++++- lib/toggle.ts | 15 ++++++++++++--- lib/types.ts | 15 ++++++++++++--- test/bind.test.ts | 14 +++++++++++++- 11 files changed, 106 insertions(+), 28 deletions(-) diff --git a/lib/argument.ts b/lib/argument.ts index 18c2d54..99a2ea0 100644 --- a/lib/argument.ts +++ b/lib/argument.ts @@ -28,9 +28,18 @@ export function argument< let phase = phases.pop()!; phases.push({ ...phase, - params: { - ...phase.params, - [added.name]: added, + model: { + params: { + ...phase.model.params, + [added.name]: added, + }, + steps: phase.model.steps.concat((current, bindings) => ({ + ok: true, + value: { + ...current, + [added.name]: bindings[added.name], + }, + })), }, }); diff --git a/lib/bind.ts b/lib/bind.ts index 6d84d34..d08b06a 100644 --- a/lib/bind.ts +++ b/lib/bind.ts @@ -97,7 +97,7 @@ export function bindPhase(options: { }): PhaseBinding { let { phase, segment } = options; let rest = options.rest; - let params = Object.values(phase.params) as Param[]; + let params = Object.values(phase.model.params) as Param[]; let pending = new Map(params.map((param) => [param.name, param])); let results = new Map>(); @@ -179,7 +179,7 @@ export function bindPhase(options: { results.set(param.name, validate(param, undefined, [param.name])); } - let model: Record = {}; + let bindings: Record = {}; let issues: Issue[] = []; let valid = true; @@ -188,14 +188,24 @@ export function bindPhase(options: { for (let param of params) { let result = results.get(param.name)!; issues.push(...result.issues ?? []); - if (result.ok) { - model[param.name] = result.value; + bindings[param.name] = result.value; } else { valid = false; } } + let model = {}; + for (let step of phase.model.steps) { + let result = step(model, bindings); + if (!result.ok) { + issues.push(...result.issues); + break; + } else { + model = result.value; + } + } + return { rest, model, issues, valid }; } diff --git a/lib/command.ts b/lib/command.ts index 4da8fe4..1cd5acf 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -19,7 +19,10 @@ export function command< ...start, methods: ["help", "execute"], phases: [{ - params: {}, + model: { + params: {}, + steps: [], + }, routes: [], values: [], envs: [], diff --git a/lib/dynamic.ts b/lib/dynamic.ts index 88f09d6..29578bf 100644 --- a/lib/dynamic.ts +++ b/lib/dynamic.ts @@ -24,7 +24,10 @@ export function dynamic< ) => (input: never) => AnyRoute, }); phases.push({ - params: {}, + model: { + params: {}, + steps: [], + }, routes: [], values: [], envs: [], diff --git a/lib/option.ts b/lib/option.ts index a4a9f34..91d0a61 100644 --- a/lib/option.ts +++ b/lib/option.ts @@ -28,9 +28,18 @@ export function option< let phase = phases.pop()!; phases.push({ ...phase, - params: { - ...phase.params, - [added.name]: added, + model: { + params: { + ...phase.model.params, + [added.name]: added, + }, + steps: phase.model.steps.concat((current, bindings) => ({ + ok: true, + value: { + ...current, + [added.name]: bindings[added.name], + }, + })), }, }); return { diff --git a/lib/parse.ts b/lib/parse.ts index 9e587fe..accc945 100644 --- a/lib/parse.ts +++ b/lib/parse.ts @@ -51,7 +51,7 @@ export function parse( envs: new Envs().mount([], input.envs ?? []), }; - return resume({ + return advance({ segments: [{ id: "/", route, @@ -70,7 +70,8 @@ export function parse( literals: literals.tokens, }); } -function resume( + +function advance( state: ParserState, ): Outcome { while (true) { @@ -203,7 +204,7 @@ function resume( ], }; - return resume({ + return advance({ ...suspended, segments: replace(suspended.segments, index, next), }); @@ -342,10 +343,17 @@ function stitch( phases.push({ ...phase, ...next, - params: { - ...phase.params, - ...next.params, + model: { + params: { + ...phase.model.params, + ...next.model.params, + }, + steps: [ + ...phase.model.steps, + ...next.model.steps, + ] }, + routes: [ ...phase.routes, ...next.routes, @@ -419,7 +427,10 @@ function seed(route: AnyRoute): AnyRoute { return { ...route, phases: [{ - params: {}, + model: { + params: {}, + steps: [], + }, routes: [], values: [], envs: [], diff --git a/lib/print.ts b/lib/print.ts index 46f33fb..86d22db 100644 --- a/lib/print.ts +++ b/lib/print.ts @@ -109,7 +109,7 @@ type Row = readonly [label: string, description?: string]; function params(route: AnyRoute): Param[] { return route.phases.flatMap((phase) => - Object.values(phase.params) as Param[] + Object.values(phase.model.params) as Param[] ); } diff --git a/lib/route.ts b/lib/route.ts index 717b255..2da3588 100644 --- a/lib/route.ts +++ b/lib/route.ts @@ -27,7 +27,10 @@ export function route< ...start, methods: ["help"], phases: [{ - params: {}, + model: { + params: {}, + steps: [], + }, routes: [], values: [], envs: [], diff --git a/lib/toggle.ts b/lib/toggle.ts index 20fa65a..e314a16 100644 --- a/lib/toggle.ts +++ b/lib/toggle.ts @@ -33,9 +33,18 @@ export function toggle< let phase = phases.pop()!; phases.push({ ...phase, - params: { - ...phase.params, - [added.name]: added, + model: { + params: { + ...phase.model.params, + [added.name]: added, + }, + steps: phase.model.steps.concat((current, bindings) => ({ + ok: true, + value: { + ...current, + [added.name]: bindings[added.name], + }, + })), }, }); diff --git a/lib/types.ts b/lib/types.ts index cabde19..56ab20f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -42,7 +42,10 @@ export type Next< T, > = { readonly [phaseModel]?: Model; - readonly params: Params; + readonly model: { + params: Params; + steps: ((previous: object, bindings: Record) => Result)[]; + } readonly routes: Routes; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; @@ -56,7 +59,10 @@ export type Done< Routes extends readonly AnyRoute[], > = { readonly [phaseModel]?: Model; - readonly params: Params; + readonly model: { + params: Params; + steps: ((previous: object, bindings: Record) => Result)[]; + } readonly routes: Routes; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; @@ -105,7 +111,10 @@ export interface AnyRoute extends Definition { export interface AnyPhase { readonly [phaseModel]?: object; - readonly params: Params; + readonly model: { + params: Params; + steps: ((previous: object, bindings: Record) => Result)[]; + }; readonly routes: readonly AnyRoute[]; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; diff --git a/test/bind.test.ts b/test/bind.test.ts index ab69985..0b60b13 100644 --- a/test/bind.test.ts +++ b/test/bind.test.ts @@ -281,7 +281,19 @@ function state(argv: string[]): Rest { function phase(params: Record>) { return { - params, + model: { + params, + steps: Object.keys(params).map((name) => ( + current: object, + bindings: Record, + ) => ({ + ok: true as const, + value: { + ...current, + [name]: bindings[name], + }, + })), + }, routes: [], values: [], envs: [], From 2c41ae08f6ff2180dc8dffba90adcef68d30785e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 26 Sep 2026 11:30:04 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Add=20schema=20model=20transfor?= =?UTF-8?q?ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bind.ts | 1 + lib/parse.ts | 2 +- lib/transform.ts | 49 +++++++++++++ lib/types.ts | 19 +++-- mod.ts | 2 + test/bind.test.ts | 3 +- test/transform.test.ts | 157 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 lib/transform.ts create mode 100644 test/transform.test.ts diff --git a/lib/bind.ts b/lib/bind.ts index d08b06a..b970709 100644 --- a/lib/bind.ts +++ b/lib/bind.ts @@ -199,6 +199,7 @@ export function bindPhase(options: { for (let step of phase.model.steps) { let result = step(model, bindings); if (!result.ok) { + valid = false; issues.push(...result.issues); break; } else { diff --git a/lib/parse.ts b/lib/parse.ts index accc945..0b3a731 100644 --- a/lib/parse.ts +++ b/lib/parse.ts @@ -351,7 +351,7 @@ function stitch( steps: [ ...phase.model.steps, ...next.model.steps, - ] + ], }, routes: [ diff --git a/lib/transform.ts b/lib/transform.ts new file mode 100644 index 0000000..c16b4e3 --- /dev/null +++ b/lib/transform.ts @@ -0,0 +1,49 @@ +import { brand, type ModelElement, type ModelTransform } from "./pipeline.ts"; +import type { AnyRoute, Schema } from "./types.ts"; + +export function transform< + Input extends object, + Output extends object, +>( + schema: Schema, +): ModelElement> { + return brand( + (route: AnyRoute) => { + let phases = [...route.phases]; + let phase = phases.pop()!; + phases.push({ + ...phase, + model: { + params: phase.model.params, + steps: phase.model.steps.concat((current) => { + let result = schema["~standard"].validate(current); + if (result instanceof Promise) { + return { + ok: false, + issues: [{ + message: `async validation not currently supported`, + }], + }; + } else if (result.issues) { + return { ok: false, issues: result.issues }; + } else { + return { ok: true, value: result.value }; + } + }), + }, + }); + return { + ...route, + phases, + }; + }, + ); +} + +interface SchemaTransform< + Input extends object, + Output extends object, +> extends ModelTransform { + readonly input: Input; + readonly output: Output; +} diff --git a/lib/types.ts b/lib/types.ts index 56ab20f..27494f8 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -44,8 +44,11 @@ export type Next< readonly [phaseModel]?: Model; readonly model: { params: Params; - steps: ((previous: object, bindings: Record) => Result)[]; - } + steps: (( + previous: object, + bindings: Record, + ) => Result)[]; + }; readonly routes: Routes; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; @@ -61,8 +64,11 @@ export type Done< readonly [phaseModel]?: Model; readonly model: { params: Params; - steps: ((previous: object, bindings: Record) => Result)[]; - } + steps: (( + previous: object, + bindings: Record, + ) => Result)[]; + }; readonly routes: Routes; readonly values: readonly ValueSource[]; readonly envs: readonly EnvSource[]; @@ -113,7 +119,10 @@ export interface AnyPhase { readonly [phaseModel]?: object; readonly model: { params: Params; - steps: ((previous: object, bindings: Record) => Result)[]; + steps: (( + previous: object, + bindings: Record, + ) => Result)[]; }; readonly routes: readonly AnyRoute[]; readonly values: readonly ValueSource[]; diff --git a/mod.ts b/mod.ts index 206f159..af1b798 100644 --- a/mod.ts +++ b/mod.ts @@ -38,6 +38,8 @@ export type { RouteZero } from "./lib/route.ts"; export { toggle } from "./lib/toggle.ts"; +export { transform } from "./lib/transform.ts"; + export { withValues } from "./lib/values.ts"; export type { ValueSource } from "./lib/values.ts"; diff --git a/test/bind.test.ts b/test/bind.test.ts index 0b60b13..97cb9bd 100644 --- a/test/bind.test.ts +++ b/test/bind.test.ts @@ -283,7 +283,8 @@ function phase(params: Record>) { return { model: { params, - steps: Object.keys(params).map((name) => ( + steps: Object.keys(params).map((name) => + ( current: object, bindings: Record, ) => ({ diff --git a/test/transform.test.ts b/test/transform.test.ts new file mode 100644 index 0000000..ac3a6e9 --- /dev/null +++ b/test/transform.test.ts @@ -0,0 +1,157 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { z } from "zod"; +import { + checkpoint, + command, + type ModelOf, + name, + option, + parse, + schema, + transform, +} from "../mod.ts"; + +const endpoint = z.object({ + port: z.number().optional(), + domain: z.string().optional(), + protocol: z.enum(["http", "https"]).optional(), +}).transform((input) => { + let protocol = input.protocol ?? "https"; + let port = input.port ?? (protocol === "https" ? 443 : 80); + + return { + port, + domain: input.domain ?? "localhost", + protocol, + }; +}); + +describe("transform()", () => { + it("replaces the current phase model with a schema output", () => { + let cooked = z.object({ raw: z.string() }).transform(({ raw }) => ({ + value: raw.toUpperCase(), + })); + let app = command( + name("replace"), + option(name("raw"), schema(z.string())), + transform(cooked), + option(name("audience"), schema(z.string())), + ); + + expectType< + Equal, { value: string; audience: string }> + >(true); + + let result = parse(app, { + argv: ["--raw", "hello", "--audience", "api"], + }); + + expect(result).toMatchObject({ ok: true, method: "execute" }); + if (!("model" in result)) return; + expect(result.model).toEqual({ value: "HELLO", audience: "api" }); + }); + + it("validates the current phase model with the schema", () => { + let positive = z.object({ + port: z.number().int().min(1).max(65_535), + }); + let app = command( + name("validated"), + option(name("port"), schema(z.number())), + transform(positive), + ); + + let result = parse(app, { argv: ["--port", "0"] }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ path: ["port"] }], + }); + }); + + it("transforms a phase after a checkpoint without exposing its bindings", () => { + let app = command( + name("auth0"), + option(name("config"), schema(z.string())), + checkpoint(), + option(name("port"), schema(z.number().optional())), + option(name("domain"), schema(z.string().optional())), + option( + name("protocol"), + schema(z.enum(["http", "https"]).optional()), + ), + transform(endpoint), + option(name("audience"), schema(z.string())), + ); + + expectType< + Equal< + ModelOf, + { + config: string; + port: number; + domain: string; + protocol: "http" | "https"; + audience: string; + } + > + >(true); + + let first = parse(app, { + argv: [ + "--config", + "auth0.json", + "--domain", + "tenant.example.com", + "--audience", + "https://example.com/api", + ], + }); + + expect(first).toMatchObject({ + ok: true, + route: "/", + model: { config: "auth0.json" }, + }); + expect("resume" in first).toBe(true); + if (!("resume" in first)) return; + + let result = first.resume({ ok: true, value: [] }); + + expect(result).toMatchObject({ ok: true, method: "execute" }); + if (!("model" in result)) return; + expect(result.model).toEqual({ + config: "auth0.json", + port: 443, + domain: "tenant.example.com", + protocol: "https", + audience: "https://example.com/api", + }); + }); + + it("rejects a schema incompatible with the active phase model", () => { + check(() => { + command( + name("incompatible"), + // @ts-expect-error the phase model lacks the required port. + transform(z.object({ port: z.number() })), + ); + }); + }); +}); + +type Equal = (() => T extends L ? 1 : 2) extends + (() => T extends R ? 1 : 2) + ? (() => T extends R ? 1 : 2) extends (() => T extends L ? 1 : 2) ? true + : false + : false; + +function expectType(_value: T): void { + // Compile-time assertion. +} + +function check(_body: () => void): void { + // Compile the callback without executing it. +}