From a091da4385e2abdef39ed1c20c3cb2fe95affe8d Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 7 Jun 2026 18:04:55 +0000 Subject: [PATCH 1/3] refactor: migrate schema/validation substrate to zod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all hand-rolled validation and the homegrown schema-dsl with zod as the single schema/validation substrate. The public builder API (flag/arg/ command) and its TypeScript inference are unchanged — only the runtime validation engine is swapped. - Add zod as the sole runtime dependency (drop the zero-dep policy in AGENTS.md/GOALS.md/README.md). - New src/core/schema/zod-kinds.ts derives the declared-type zod schema for a flag/arg on demand (flagZod/argZod/buildZodSchema); not stored on the descriptor, so schemas stay plain, comparable, JSON-serialisable objects. - resolve/coerce.ts and parse/index.ts now drive string/number/enum accept decisions through zod (safeParse); all ValidationError/ParseError messages, codes, details, and suggestions are preserved byte-for-byte. Custom parseFn still runs directly (not wrapped in zod). - json-schema: generateInputSchema and definitionMetaSchema now built via z.toJSONSchema (registry + post-processor for $defs/$ref and conventions); output is byte-compatible with prior assertions. - Delete src/core/schema-dsl/ entirely (tokenizer/parser/AST/validator/ json-schema emitter); json-schema was its only consumer. - config/package.json validation now uses zod, preserving exact failure semantics (return-null vs CONFIG_PARSE_ERROR wrapping). - Consolidate duplicated isRecord/isPlainObject guards into src/core/internal/guards.ts. Verified: tsgo typecheck, biome lint, dprint format, meta-descriptions:check, 2205 vitest tests, tsdown build + attw + publint all green. --- AGENTS.md | 5 +- GOALS.md | 3 +- README.md | 2 +- bun.lock | 7 +- package.json | 3 + src/core/completion/shells/shared.ts | 17 +- src/core/config/index.ts | 28 +- src/core/config/package-json.ts | 85 ++-- src/core/internal/guards.ts | 36 ++ src/core/json-schema/index.ts | 415 +++++++++------ src/core/json-schema/json-schema.test.ts | 15 +- src/core/parse/index.ts | 25 +- src/core/resolve/coerce.ts | 56 +-- src/core/resolve/errors.ts | 5 +- src/core/schema-dsl/AGENTS.md | 34 -- src/core/schema-dsl/index.ts | 109 ---- src/core/schema-dsl/parse.ts | 262 ---------- src/core/schema-dsl/runtime.ts | 609 ----------------------- src/core/schema-dsl/schema-dsl.test.ts | 425 ---------------- src/core/schema-dsl/to-json-schema.ts | 102 ---- src/core/schema/zod-kinds.ts | 84 ++++ 21 files changed, 503 insertions(+), 1824 deletions(-) create mode 100644 src/core/internal/guards.ts delete mode 100644 src/core/schema-dsl/AGENTS.md delete mode 100644 src/core/schema-dsl/index.ts delete mode 100644 src/core/schema-dsl/parse.ts delete mode 100644 src/core/schema-dsl/runtime.ts delete mode 100644 src/core/schema-dsl/schema-dsl.test.ts delete mode 100644 src/core/schema-dsl/to-json-schema.ts create mode 100644 src/core/schema/zod-kinds.ts diff --git a/AGENTS.md b/AGENTS.md index 2c8ab900..32badf31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,8 @@ ## OVERVIEW -Schema-first, fully typed TypeScript CLI framework. Zero runtime deps. In-repo exports point at +Schema-first, fully typed TypeScript CLI framework. `zod` is the sole runtime dependency — the +schema/validation substrate behind parsing, coercion, and JSON Schema generation. In-repo exports point at `src/*.ts`; published Node defaults point at `dist/*.mjs`, while Bun and Deno keep source exports. Read `@DISCOVERIES.md` before planning, editing, or running task workflows. @@ -79,7 +80,7 @@ specs/ # planning/design docs ## ANTI-PATTERNS (THIS PROJECT) -- Do not add runtime deps +- Keep runtime deps minimal — `zod` is the only one; justify any further addition - Do not use `process.*` or runtime-specific APIs in `src/core/` - Do not import through barrels when it would create cycles; direct-file imports are intentional in `cli/`, `completion/`, `output/`, `prompt/`, `resolve/`, and `runtime/` diff --git a/GOALS.md b/GOALS.md index dfba1fa3..54a04301 100644 --- a/GOALS.md +++ b/GOALS.md @@ -350,7 +350,8 @@ Adapters: ### 8) Non-functional requirements - **TypeScript ergonomics:** avoid type explosions that make TS slow or unreadable in editor hovers. -- **Small dependency footprint:** prefer zero-dep core; optional extras behind adapters/plugins. +- **Small dependency footprint:** lean core with `zod` as the sole runtime dependency (the + schema/validation substrate); optional extras behind adapters/plugins. - **Tree-shakeable ESM:** modern packaging with ESM-only exports and clear defaults. - **Deterministic tests:** no reliance on wall-clock time, real filesystem, or actual TTY unless explicitly integrated. diff --git a/README.md b/README.md index 69fcbb8f..ab97f315 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![NPM](https://img.shields.io/npm/v/@kjanat/dreamcli?logo=npm&labelColor=CB3837&color=black)][npm] [![JSR](https://img.shields.io/jsr/v/@kjanat/dreamcli?logoColor=083344&logo=jsr&logoSize=auto&label=&labelColor=f7df1e&color=black)][jsr] -Schema-first, fully typed TypeScript CLI framework. Zero runtime dependencies. +Schema-first, fully typed TypeScript CLI framework, powered by [zod](https://zod.dev) as its schema and validation substrate. One flag declaration configures the entire resolution pipeline: diff --git a/bun.lock b/bun.lock index 2ef8fefd..4f3b6642 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "@kjanat/dreamcli", + "dependencies": { + "zod": "4.4.3", + }, "devDependencies": { "@arethetypeswrong/core": "^0.18.2", "@biomejs/biome": "^2.4.10", @@ -1280,7 +1283,7 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1332,6 +1335,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "knip/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "rolldown/@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], diff --git a/package.json b/package.json index 3c217881..5dd5a837 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,9 @@ "overrides": { "vitepress": "next" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@arethetypeswrong/core": "^0.18.2", "@biomejs/biome": "^2.4.10", diff --git a/src/core/completion/shells/shared.ts b/src/core/completion/shells/shared.ts index cbdb9242..69899ddc 100644 --- a/src/core/completion/shells/shared.ts +++ b/src/core/completion/shells/shared.ts @@ -11,6 +11,7 @@ import { collectPropagatedFlags } from '#internals/core/cli/propagate.ts'; import { resolveRootSurface } from '#internals/core/cli/root-surface.ts'; +import { createSchema } from '#internals/core/schema/index.ts'; import type { CommandSchema, FlagSchema } from '#internals/core/schema/index.ts'; import { DREAMCLI_REVISION, DREAMCLI_VERSION } from '#internals/version.ts'; @@ -145,21 +146,7 @@ function createRootFlags(hasVersion: boolean): Readonly { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} +/** + * Zod schema validating that parsed config is a plain object. + * + * Backs the shape check in {@link discoverConfig}. A `z.custom` wrapping the + * shared {@link isPlainObject} guard is used (rather than `z.record` / + * `z.looseObject`) so the strict prototype semantics are preserved exactly: + * arrays, primitives, `null`, and class instances are rejected, while + * `Object.create(null)` records are accepted. + * + * @internal + */ +const configShapeSchema = z.custom>((value) => isPlainObject(value)); // --- Types — format loaders @@ -328,11 +334,11 @@ async function discoverConfig( } try { - const data = loader.parse(content); - if (!isPlainObject(data)) { + const parsed = configShapeSchema.safeParse(loader.parse(content)); + if (!parsed.success) { throw new Error('Config loader must return a plain object'); } - return { found: true, path: candidatePath, data, format: ext }; + return { found: true, path: candidatePath, data: parsed.data, format: ext }; } catch (cause: unknown) { throw new CLIError(`Failed to parse config file: ${candidatePath}`, { code: 'CONFIG_PARSE_ERROR', diff --git a/src/core/config/package-json.ts b/src/core/config/package-json.ts index 10d0bd60..d4dbc1e2 100644 --- a/src/core/config/package-json.ts +++ b/src/core/config/package-json.ts @@ -8,14 +8,47 @@ * @module dreamcli/core/config/package-json */ +import { z } from 'zod'; +import { isRecord } from '#internals/core/internal/guards.ts'; import type { RuntimeAdapter } from '#internals/runtime/adapter.ts'; -// --- Narrowing helpers +// --- Validation schemas -/** Type guard: narrows `unknown` to a plain (non-array) object. */ -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} +/** + * Zod schema describing the package.json fields this module reads. + * + * The root must be a loose object (shared {@link isRecord} semantics — any + * non-null, non-array object). Each field is independently coerced: non-string + * `name`/`version`/`description` and malformed `bin` values are dropped rather + * than rejected, matching the historical hand-checked extraction. Parsing never + * throws here — callers convert failure to a `null` return. + * + * @internal + */ +const binSchema = z.union([ + z.string(), + // Object bin: must be a loose (non-array) object whose values are all + // strings; any non-string value drops the whole field (parse fails). + z + .custom>((v) => isRecord(v)) + .refine((v) => Object.values(v).every((entry) => typeof entry === 'string')), +]); + +/** @internal */ +const packageJsonSchema = z + .custom>((value) => isRecord(value)) + .transform((obj) => { + const name = z.string().safeParse(obj['name']); + const version = z.string().safeParse(obj['version']); + const description = z.string().safeParse(obj['description']); + const bin = binSchema.safeParse(obj['bin']); + return { + ...(name.success ? { name: name.data } : {}), + ...(version.success ? { version: version.data } : {}), + ...(description.success ? { description: description.data } : {}), + ...(bin.success ? { bin: bin.data } : {}), + } satisfies PackageJsonData; + }); // --- Types @@ -139,45 +172,17 @@ async function discoverPackageJson(adapter: PackageJsonAdapter): Promise> | undefined { - if (typeof value === 'string') return value; - if (!isPlainObject(value)) return undefined; - const result: Record = {}; - for (const [k, v] of Object.entries(value)) { - if (typeof v !== 'string') return undefined; - result[k] = v; - } - return result; + // zod validates the root shape and extracts/coerces fields. A non-object + // root fails the schema, yielding the historical `null` return rather than + // a thrown error. + const result = packageJsonSchema.safeParse(parsed); + return result.success ? result.data : null; } // --- inferCliName diff --git a/src/core/internal/guards.ts b/src/core/internal/guards.ts new file mode 100644 index 00000000..2b991367 --- /dev/null +++ b/src/core/internal/guards.ts @@ -0,0 +1,36 @@ +/** + * Shared structural type guards. + * + * Consolidates the small `isRecord` / `isPlainObject` checks that were + * previously duplicated across config, json-schema, and resolution modules. + * + * @module dreamcli/core/internal/guards + * @internal + */ + +/** + * Loose object guard — any non-null, non-array `object`. + * + * Use when narrowing arbitrary values (error details, JSON walk) where exotic + * prototypes are acceptable. + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Strict plain-object guard — a non-null, non-array object whose prototype is + * `Object.prototype` or `null`. + * + * Use when validating parsed external data (config files, package.json) where + * only structural JSON objects should pass. + */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export { isPlainObject, isRecord }; diff --git a/src/core/json-schema/index.ts b/src/core/json-schema/index.ts index 02552010..56bd4e12 100644 --- a/src/core/json-schema/index.ts +++ b/src/core/json-schema/index.ts @@ -13,7 +13,9 @@ * @module dreamcli/core/json-schema */ +import { z } from 'zod'; import type { CLISchema } from '#internals/core/cli/index.ts'; +import { isRecord } from '#internals/core/internal/guards.ts'; import { getFlagAliasNames } from '#internals/core/schema/flag.ts'; import type { ArgSchema, @@ -24,8 +26,7 @@ import type { PromptConfig, SelectChoice, } from '#internals/core/schema/index.ts'; -import { parseSchema } from '#internals/core/schema-dsl/runtime.ts'; -import { nodeToJsonSchema } from '#internals/core/schema-dsl/to-json-schema.ts'; +import { argZod, flagZod } from '#internals/core/schema/zod-kinds.ts'; import { definitionMetaSchemaDescriptions } from './meta-descriptions.generated.ts'; // --- Options @@ -96,8 +97,14 @@ const DEFINITION_SCHEMA_URL = 'https://cdn.jsdelivr.net/npm/@kjanat/dreamcli/dre /** Meta-schema URL for JSON Schema draft 2020-12 (input validation). */ const JSON_SCHEMA_DRAFT = 'https://json-schema.org/draft/2020-12/schema'; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); +/** + * Strip zod's emitted `$schema` (and any `$id`) key from a `z.toJSONSchema` + * fragment. zod stamps the meta-schema URL on the top-level fragment; the + * envelope is rebuilt by the callers in this module. + */ +function stripJsonSchemaMeta(fragment: Record): Record { + const { $schema: _schema, $id: _id, ...rest } = fragment; + return rest; } // === Definition schema — generateSchema() @@ -570,91 +577,57 @@ function getBranchCommandDiscriminator(branch: Record): string : undefined; } -// --- Type mapping — flags → JSON Schema types +// --- Type mapping — flags / args → JSON Schema types (zod-derived) +/** + * Build the JSON Schema type fragment for a flag from its declared-type zod + * schema (`flagZod`), then layer on JSON Schema annotations. + * + * The shape produced by `z.toJSONSchema` already matches the conventions the + * input schema asserts: `string`→`{type:'string'}`, `number`→`{type:'number'}`, + * `boolean`→`{type:'boolean'}`, `enum`→`{type:'string',enum:[...]}`, + * `array`→`{type:'array',items:{...}}`, `custom`/`unknown`→`{}`. The only + * post-processing is stripping zod's `$schema` key and attaching + * `description` / `default` / `deprecated`. + */ function flagToJsonSchemaType(schema: FlagSchema): Record { - const result: Record = {}; - - switch (schema.kind) { - case 'string': - result.type = 'string'; - break; - case 'number': - result.type = 'number'; - break; - case 'boolean': - result.type = 'boolean'; - break; - case 'enum': - result.type = 'string'; - if (schema.enumValues !== undefined) { - result.enum = [...schema.enumValues]; - } - break; - case 'array': - result.type = 'array'; - if (schema.elementSchema !== undefined) { - result.items = flagToJsonSchemaType(schema.elementSchema); - } - break; - case 'custom': - // Opaque type — no JSON Schema constraint - break; - } - - if (schema.description !== undefined) { - result.description = schema.description; - } - if (schema.presence === 'defaulted' && isJsonSerializable(schema.defaultValue)) { - result.default = schema.defaultValue; - } - if (schema.deprecated !== undefined) { - result.deprecated = schema.deprecated; - } - - return result; + const result = stripJsonSchemaMeta(z.toJSONSchema(flagZod(schema))); + return annotateInputType(result, schema.description, schema.defaultValue, schema, schema.deprecated); } -// --- Type mapping — args → JSON Schema types - +/** + * Build the JSON Schema type fragment for a positional arg from its + * declared-type zod schema (`argZod`), wrapping variadic args in an array. + */ function argToJsonSchemaType(schema: ArgSchema): Record { - const kind = argKindToType(schema); + const kind = stripJsonSchemaMeta(z.toJSONSchema(argZod(schema))); const result: Record = schema.variadic ? { type: 'array', items: kind } - : { ...kind }; + : kind; - if (schema.description !== undefined) { - result.description = schema.description; + return annotateInputType(result, schema.description, schema.defaultValue, schema, schema.deprecated); +} + +/** Attach JSON Schema `description`/`default`/`deprecated` annotations. */ +function annotateInputType( + result: Record, + description: string | undefined, + defaultValue: unknown, + schema: { readonly presence: string }, + deprecated: string | boolean | undefined, +): Record { + if (description !== undefined) { + result.description = description; } - if (schema.presence === 'defaulted' && isJsonSerializable(schema.defaultValue)) { - result.default = schema.defaultValue; + if (schema.presence === 'defaulted' && isJsonSerializable(defaultValue)) { + result.default = defaultValue; } - if (schema.deprecated !== undefined) { - result.deprecated = schema.deprecated; + if (deprecated !== undefined) { + result.deprecated = deprecated; } - return result; } -/** Map an arg's kind to a JSON Schema type fragment. */ -function argKindToType(schema: ArgSchema): Record { - switch (schema.kind) { - case 'string': - return { type: 'string' }; - case 'number': - return { type: 'number' }; - case 'enum': { - const result: Record = { type: 'string' }; - if (schema.enumValues !== undefined) { - result.enum = [...schema.enumValues]; - } - return result; - } - case 'custom': - return {}; - } -} - // === Utilities /** @@ -710,11 +683,83 @@ function isPlainJsonObject(value: object): value is Record { return proto === Object.prototype || proto === null; } -// === Definition meta-schema — derived from schema DSL definitions +// === Definition meta-schema — derived from zod object schemas + +/** + * Normalize a `z.toJSONSchema` fragment to the JSON Schema conventions the + * definition meta-schema has always emitted (previously hand-built from a + * schema DSL): + * + * - strip zod's `$schema` / `$id` keys + * - literal-string union (`{type:'string',enum:[...]}`) → `{enum:[...]}` + * - boolean literal (`{type:'boolean',const:X}`) → `{const:X}` + * - `z.record(...)` (`{type:'object',propertyNames,additionalProperties}`) + * → `{type:'object',additionalProperties:...}` (drop `propertyNames`) + * - `z.int()` safe-integer bounds → plain `{type:'integer'}` + * - `anyOf` → `oneOf` + * + * Recurses through `properties`, `items`, `additionalProperties`, and + * `oneOf` / `anyOf` member lists. Cross-references emitted by the registry as + * `{$ref:'#/$defs/'}` are preserved verbatim. + */ +function normalizeDefFragment(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeDefFragment); + } + if (!isRecord(value)) { + return value; + } + + const node = stripJsonSchemaMeta(value); + + // Literal: `{type:, const: …}` → `{const: …}` (drop redundant + // `type`, matching the prior DSL literal convention for both `true`/`false` + // and string literals such as the `$schema` const). + if ('const' in node && (node.type === 'boolean' || node.type === 'string')) { + const { type: _type, ...rest } = node; + return rest; + } + + // Literal-string union: `{type:'string', enum:[…]}` → `{enum:[…]}`. + if (node.type === 'string' && Array.isArray(node.enum)) { + const { type: _type, ...rest } = node; + return rest; + } -/** Convert a DSL string to a JSON Schema object definition. */ -function def(source: string): Record { - return nodeToJsonSchema(parseSchema(source)); + // `z.int()` stamps safe-integer min/max bounds; the meta-schema models a + // plain integer. + if (node.type === 'integer') { + const SAFE = 9007199254740991; + if (node.minimum === -SAFE && node.maximum === SAFE) { + const { minimum: _min, maximum: _max, ...rest } = node; + return rest; + } + } + + const result: Record = {}; + for (const [key, child] of Object.entries(node)) { + // `z.record(...)` adds `propertyNames`; the meta-schema only keeps + // `additionalProperties`. + if (key === 'propertyNames') { + continue; + } + // `properties` / `$defs` are name→schema maps: normalize each value, but + // never treat the map itself as a schema fragment (its keys may legitimately + // be `$schema`/`$id`, e.g. the root's `$schema` literal property). + if ((key === 'properties' || key === '$defs') && isRecord(child)) { + result[key] = Object.fromEntries( + Object.entries(child).map(([name, sub]) => [name, normalizeDefFragment(sub)]), + ); + continue; + } + // Normalize mixed unions to `oneOf` (matches the prior DSL output). + if (key === 'anyOf') { + result.oneOf = normalizeDefFragment(child); + continue; + } + result[key] = normalizeDefFragment(child); + } + return result; } interface DefinitionMetaSchemaDescriptionNode { @@ -792,10 +837,11 @@ function withDefinitionMetaSchemaDescriptions( /** * JSON Schema (draft 2020-12) that validates the output of {@link generateSchema}. * - * Each `$defs` entry is defined once as a schema DSL string — the DSL - * parser produces a runtime AST, and {@link nodeToJsonSchema} converts - * that AST to a JSON Schema fragment. No probe fixtures, no override - * maps, no manually maintained type definitions. + * Each `$defs` entry is declared once as a zod object schema. `z.toJSONSchema` + * (via a local registry, so the six named schemas cross-reference through + * `$ref: '#/$defs/'`) produces the fragments, which are normalized + * ({@link normalizeDefFragment}) to the conventions this schema has always + * emitted. No probe fixtures, override maps, or hand-maintained JSON. * * Hosted at {@link DEFINITION_SCHEMA_URL} for `$schema` resolution. Also * exported so tooling can validate definition documents without a network @@ -811,79 +857,138 @@ function withDefinitionMetaSchemaDescriptions( * const valid = validate(generateSchema(myCli.schema)); * ``` */ -const definitionMetaSchema: Record = withDefinitionMetaSchemaDescriptions( - { - $schema: JSON_SCHEMA_DRAFT, - $id: DEFINITION_SCHEMA_URL, - title: '@kjanat/dreamcli definition schema', - description: - 'Describes the structure of a CLI built with dreamcli — commands, flags, args, types, constraints, env bindings, and prompts.', - ...def(`{ - $schema: '${DEFINITION_SCHEMA_URL}'; - name: string; - version?: string; - description?: string; - defaultCommand?: string; - commands: @command[] - }`), - $defs: { - command: def(`{ - name: string; - description?: string; - aliases?: string[]; - hidden?: true; - examples?: @example[]; - flags: Record; - args: @arg[]; - commands: @command[] - }`), - flag: def(`{ - kind: 'string' | 'number' | 'boolean' | 'enum' | 'array' | 'custom'; - presence: 'optional' | 'required' | 'defaulted'; - defaultValue?: unknown; - aliases?: string[]; - envVar?: string; - configPath?: string; - description?: string; - enumValues?: string[]; - elementSchema?: @flag; - prompt?: @prompt; - deprecated?: string | true; - propagate?: true - }`), - arg: def(`{ - name: string; - kind: 'string' | 'number' | 'enum' | 'custom'; - presence: 'required' | 'optional' | 'defaulted'; - variadic?: true; - stdinMode?: true; - defaultValue?: unknown; - description?: string; - envVar?: string; - enumValues?: string[]; - deprecated?: string | true - }`), - prompt: def(`{ - kind: 'confirm' | 'input' | 'select' | 'multiselect'; - message: string; - placeholder?: string; - choices?: @choice[]; - min?: integer; - max?: integer - }`), - choice: def(`{ - value: string; - label?: string; - description?: string - }`), - example: def(`{ - command: string; - description?: string - }`), +const definitionMetaSchema: Record = buildDefinitionMetaSchema(); + +/** + * Construct the definition meta-schema from zod object schemas. + * + * @internal + */ +function buildDefinitionMetaSchema(): Record { + const registry = z.registry<{ id: string }>(); + const named = (schema: T, id: string): T => { + registry.add(schema, { id }); + return schema; + }; + + const choice = named( + z.object({ + value: z.string(), + label: z.string().optional(), + description: z.string().optional(), + }), + 'choice', + ); + + const example = named( + z.object({ + command: z.string(), + description: z.string().optional(), + }), + 'example', + ); + + const prompt = named( + z.object({ + kind: z.enum(['confirm', 'input', 'select', 'multiselect']), + message: z.string(), + placeholder: z.string().optional(), + choices: z.array(choice).optional(), + min: z.int().optional(), + max: z.int().optional(), + }), + 'prompt', + ); + + const flag: z.ZodType = named( + z.object({ + kind: z.enum(['string', 'number', 'boolean', 'enum', 'array', 'custom']), + presence: z.enum(['optional', 'required', 'defaulted']), + defaultValue: z.unknown().optional(), + aliases: z.array(z.string()).optional(), + envVar: z.string().optional(), + configPath: z.string().optional(), + description: z.string().optional(), + enumValues: z.array(z.string()).optional(), + elementSchema: z.lazy(() => flag).optional(), + prompt: prompt.optional(), + deprecated: z.union([z.string(), z.literal(true)]).optional(), + propagate: z.literal(true).optional(), + }), + 'flag', + ); + + const arg = named( + z.object({ + name: z.string(), + kind: z.enum(['string', 'number', 'enum', 'custom']), + presence: z.enum(['required', 'optional', 'defaulted']), + variadic: z.literal(true).optional(), + stdinMode: z.literal(true).optional(), + defaultValue: z.unknown().optional(), + description: z.string().optional(), + envVar: z.string().optional(), + enumValues: z.array(z.string()).optional(), + deprecated: z.union([z.string(), z.literal(true)]).optional(), + }), + 'arg', + ); + + const command: z.ZodType = named( + z.object({ + name: z.string(), + description: z.string().optional(), + aliases: z.array(z.string()).optional(), + hidden: z.literal(true).optional(), + examples: z.array(example).optional(), + flags: z.record(z.string(), flag), + args: z.array(arg), + commands: z.array(z.lazy(() => command)), + }), + 'command', + ); + + named( + z.object({ + $schema: z.literal(DEFINITION_SCHEMA_URL), + name: z.string(), + version: z.string().optional(), + description: z.string().optional(), + defaultCommand: z.string().optional(), + commands: z.array(command), + }), + 'root', + ); + + // Render every named schema (root + the six `$defs`) in one pass so all + // cross-references resolve to `#/$defs/`, then normalize to the + // meta-schema's conventions. + const registryOutput = z.toJSONSchema(registry, { uri: (id) => `#/$defs/${id}` }); + const registrySchemas = isRecord(registryOutput.schemas) ? registryOutput.schemas : {}; + const defs: Record = {}; + for (const name of ['command', 'flag', 'arg', 'prompt', 'choice', 'example']) { + const fragment = registrySchemas[name]; + if (isRecord(fragment)) { + defs[name] = normalizeDefFragment(fragment); + } + } + + const rootFragment = normalizeDefFragment(registrySchemas.root); + const rootObject = isRecord(rootFragment) ? rootFragment : {}; + + return withDefinitionMetaSchemaDescriptions( + { + $schema: JSON_SCHEMA_DRAFT, + $id: DEFINITION_SCHEMA_URL, + title: '@kjanat/dreamcli definition schema', + description: + 'Describes the structure of a CLI built with dreamcli — commands, flags, args, types, constraints, env bindings, and prompts.', + ...rootObject, + $defs: defs, }, - }, - definitionMetaSchemaDescriptions, -); + definitionMetaSchemaDescriptions, + ); +} // === Exports diff --git a/src/core/json-schema/json-schema.test.ts b/src/core/json-schema/json-schema.test.ts index 6b969b1d..47891141 100644 --- a/src/core/json-schema/json-schema.test.ts +++ b/src/core/json-schema/json-schema.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import type { CLISchema } from '#internals/core/cli/index.ts'; +import { createArgSchema } from '#internals/core/schema/arg.ts'; import { createSchema } from '#internals/core/schema/flag.ts'; import type { ActivityEvent, @@ -88,19 +89,7 @@ function argEntry( ): CommandArgEntry { return { name, - schema: { - kind: 'string', - presence: 'required', - variadic: false, - stdinMode: false, - defaultValue: undefined, - description: undefined, - envVar: undefined, - enumValues: undefined, - parseFn: undefined, - deprecated: undefined, - ...overrides, - }, + schema: createArgSchema(overrides.kind ?? 'string', overrides), }; } diff --git a/src/core/parse/index.ts b/src/core/parse/index.ts index 2b92809c..ad9c1737 100644 --- a/src/core/parse/index.ts +++ b/src/core/parse/index.ts @@ -15,6 +15,7 @@ import { ParseError } from '#internals/core/errors/index.ts'; import { getFlagAliasNames } from '#internals/core/schema/flag.ts'; +import { buildZodSchema } from '#internals/core/schema/zod-kinds.ts'; import type { ArgSchema, CommandArgEntry, @@ -168,17 +169,18 @@ function coerceFlagValue( ): unknown { switch (schema.kind) { case 'string': - return raw; + return buildZodSchema('string').parse(raw); case 'number': { const n = Number(raw); - if (Number.isNaN(n)) { + const result = buildZodSchema('number').safeParse(n); + if (!result.success) { throw new ParseError(`Invalid number value '${raw}' for flag ${displayName}`, { code: 'INVALID_VALUE', details: { flag: flagName, input: displayName, value: raw, expected: 'number' }, }); } - return n; + return result.data; } case 'boolean': @@ -204,7 +206,8 @@ function coerceFlagValue( }, ); } - if (!allowed.includes(raw)) { + const result = buildZodSchema('enum', { enumValues: allowed }).safeParse(raw); + if (!result.success) { throw new ParseError( `Invalid value '${raw}' for flag ${displayName}. Allowed: ${allowed.join(', ')}`, { @@ -213,7 +216,7 @@ function coerceFlagValue( }, ); } - return raw; + return result.data; } case 'array': @@ -254,17 +257,18 @@ function coerceFlagValue( function coerceArgValue(argName: string, raw: string, schema: ArgSchema): unknown { switch (schema.kind) { case 'string': - return raw; + return buildZodSchema('string').parse(raw); case 'number': { const n = Number(raw); - if (Number.isNaN(n)) { + const result = buildZodSchema('number').safeParse(n); + if (!result.success) { throw new ParseError(`Invalid number value '${raw}' for argument <${argName}>`, { code: 'INVALID_VALUE', details: { arg: argName, value: raw, expected: 'number' }, }); } - return n; + return result.data; } case 'enum': { @@ -278,7 +282,8 @@ function coerceArgValue(argName: string, raw: string, schema: ArgSchema): unknow }, ); } - if (!allowed.includes(raw)) { + const result = buildZodSchema('enum', { enumValues: allowed }).safeParse(raw); + if (!result.success) { throw new ParseError( `Invalid value '${raw}' for argument <${argName}>. Allowed: ${allowed.join(', ')}`, { @@ -287,7 +292,7 @@ function coerceArgValue(argName: string, raw: string, schema: ArgSchema): unknow }, ); } - return raw; + return result.data; } case 'custom': { diff --git a/src/core/resolve/coerce.ts b/src/core/resolve/coerce.ts index de9c19b6..8128a17d 100644 --- a/src/core/resolve/coerce.ts +++ b/src/core/resolve/coerce.ts @@ -8,6 +8,7 @@ import type { ValidationErrorCode } from '#internals/core/errors/index.ts'; import { ValidationError } from '#internals/core/errors/index.ts'; import type { ArgSchema, FlagSchema } from '#internals/core/schema/index.ts'; +import { buildZodSchema } from '#internals/core/schema/zod-kinds.ts'; import type { ArgDiagnosticSource, FlagDiagnosticSource } from './contracts.ts'; import type { SharedPropertySchema } from './property.ts'; import { toSharedArgPropertySchema, toSharedFlagPropertySchema } from './property.ts'; @@ -165,11 +166,16 @@ function coerceSharedPropertyValue( ): CoerceResult { switch (schema.kind) { case 'string': { - if (typeof raw === 'string') return { ok: true, value: raw }; - if (source.kind === 'prompt') return { ok: true, value: String(raw) }; - if (source.kind === 'config' && (typeof raw === 'number' || typeof raw === 'boolean')) { - return { ok: true, value: String(raw) }; - } + // Source-specific normalization: prompt always stringifies; config + // permits number/boolean → String(raw). The resulting value is then + // validated by the canonical declared-type zod schema (z.string()). + const normalized = + source.kind === 'prompt' || + (source.kind === 'config' && (typeof raw === 'number' || typeof raw === 'boolean')) + ? String(raw) + : raw; + const parsed = buildZodSchema('string').safeParse(normalized); + if (parsed.success) return { ok: true, value: parsed.data }; return coercionError( flagName, source, @@ -184,35 +190,22 @@ function coerceSharedPropertyValue( } case 'number': { - if (typeof raw === 'number') { - if (Number.isNaN(raw)) { - return coercionError( - flagName, - source, - 'TYPE_MISMATCH', - 'number', - raw, - 'Invalid number value NaN', - source.kind === 'env' - ? `Set ${source.envVar} to a valid number` - : source.kind === 'config' - ? `Set ${source.configPath} to a valid number in your config` - : `Enter a valid number for --${flagName}`, - ); - } - return { ok: true, value: raw }; - } - if (typeof raw === 'string') { - const value = Number(raw); - if (!Number.isNaN(value)) return { ok: true, value }; - } + // Source-specific normalization: numeric strings coerce to Number + // before the canonical z.number() schema (which rejects NaN) decides. + const normalized = typeof raw === 'string' ? Number(raw) : raw; + const parsed = buildZodSchema('number').safeParse(normalized); + if (parsed.success) return { ok: true, value: parsed.data }; return coercionError( flagName, source, 'TYPE_MISMATCH', 'number', raw, - typeof raw === 'string' ? `Invalid number value '${raw}'` : 'Invalid number value', + typeof raw === 'number' + ? 'Invalid number value NaN' + : typeof raw === 'string' + ? `Invalid number value '${raw}'` + : 'Invalid number value', source.kind === 'env' ? `Set ${source.envVar} to a valid number` : source.kind === 'config' @@ -223,8 +216,11 @@ function coerceSharedPropertyValue( case 'enum': { const allowed = schema.enumValues ?? []; - if (typeof raw === 'string' && allowed.includes(raw)) { - return { ok: true, value: raw }; + // z.enum(allowed) is the membership decision; falls back to z.string() + // when no values are declared, which still rejects non-strings. + const parsed = buildZodSchema('enum', { enumValues: allowed }).safeParse(raw); + if (parsed.success && allowed.includes(parsed.data as string)) { + return { ok: true, value: parsed.data }; } return { ok: false, diff --git a/src/core/resolve/errors.ts b/src/core/resolve/errors.ts index bb2fc31e..5443d885 100644 --- a/src/core/resolve/errors.ts +++ b/src/core/resolve/errors.ts @@ -10,6 +10,7 @@ import type { ValidationErrorCode } from '#internals/core/errors/index.ts'; import { ValidationError } from '#internals/core/errors/index.ts'; +import { isRecord } from '#internals/core/internal/guards.ts'; type AggregateIssueSourceKind = 'env' | 'config' | 'stdin' | 'prompt'; @@ -23,10 +24,6 @@ interface AggregateIssueSummary { readonly sourceLabel?: string; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function readStringProperty(record: object, key: string): string | undefined { const value = Reflect.get(record, key); return typeof value === 'string' ? value : undefined; diff --git a/src/core/schema-dsl/AGENTS.md b/src/core/schema-dsl/AGENTS.md deleted file mode 100644 index a6bebdc0..00000000 --- a/src/core/schema-dsl/AGENTS.md +++ /dev/null @@ -1,34 +0,0 @@ -# schema-dsl — String-literal schema definitions with compile-time type inference - -Enables defining commands from string-literal flag/arg specifications with full TypeScript inference. -Separate from `schema/` because it layers on top — consumers define schemas as strings, this module -parses them into typed `CommandBuilder` instances at compile time and runtime. - -## FILES - -| File | Lines | Purpose | -| -------------------- | ----: | ----------------------------------------------------------------- | -| `index.ts` | 109 | Barrel — public API + `define()` factory function | -| `parse.ts` | 241 | Compile-time string literal type parsing (template literal types) | -| `runtime.ts` | 574 | Runtime parser — string -> FlagBuilder/ArgBuilder construction | -| `to-json-schema.ts` | 99 | DSL definition -> JSON Schema conversion | -| `schema-dsl.test.ts` | 373 | Tests for both compile-time and runtime parsing | - -## ARCHITECTURE - -Two parallel paths from the same string input: - -1. **Compile-time**: `parse.ts` uses template literal types to extract flag names, types, and - optionality from string definitions -> full type inference in `.action()` handler -2. **Runtime**: `runtime.ts` parses the same strings into `FlagBuilder`/`ArgBuilder` calls -> - produces real `CommandBuilder` instance - -Both paths must agree — a string that type-checks must also produce the correct runtime behavior. - -## GOTCHAS - -- `runtime.ts` (574 lines) is the largest file — contains the full runtime string parser -- Template literal type parsing in `parse.ts` is pure type-level code (zero runtime) -- `to-json-schema.ts` bridges DSL definitions to the `json-schema/` module -- Imports from `schema/` (flag, arg, command builders) — not circular because schema-dsl depends on - schema, not vice versa diff --git a/src/core/schema-dsl/index.ts b/src/core/schema-dsl/index.ts deleted file mode 100644 index 8f945ed3..00000000 --- a/src/core/schema-dsl/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Schema DSL — single-source schema definitions with compile-time types - * and runtime AST. - * - * Define a schema once as a string literal. At compile time, the string is - * parsed into a TypeScript type via {@link Parse}. At runtime, the same - * string is parsed into an AST ({@link SchemaNode}) for validation, - * JSON Schema generation, and introspection. - * - * ```ts - * const user = schema('{ name: string; age: number; tags?: string[] }'); - * - * // Compile-time: inferred as { name: string; age: number; tags?: string[] } - * const data = user.parse(jsonInput); - * data.name; // string - * data.age; // number - * ``` - * - * @module dreamcli/core/schema-dsl - */ - -import type { Parse } from './parse.ts'; -import type { SchemaNode } from './runtime.ts'; -import { parseSchema, validateNode } from './runtime.ts'; - -// === Schema definition === - -/** The result of calling {@link schema}. Bundles source, AST, guard, and parse. */ -interface SchemaDefinition { - /** The original source string. */ - readonly source: T; - /** Runtime AST parsed from the source. */ - readonly ast: SchemaNode; - /** - * Type guard — returns `true` if `input` matches the schema. - * Narrows the type to `Parse` in the true branch. - */ - guard(input: unknown): input is Parse; - /** - * Parse and validate — returns the narrowed value or throws. - * @throws {TypeError} if the input does not match the schema. - */ - parse(input: unknown): Parse; -} - -/** - * Define a schema from a string literal. - * - * @param source - Schema string (e.g. `"{ name: string; age: number }"`). - * @returns A {@link SchemaDefinition} with compile-time types and runtime validation. - * - * @example - * ```ts - * const user = schema('{ name: string; age: number; admin?: boolean }'); - * - * // Type guard - * if (user.guard(input)) { - * input.name; // string - * } - * - * // Parse or throw - * const data = user.parse(input); - * data.age; // number - * ``` - */ -function schema(source: T): SchemaDefinition { - const ast = parseSchema(source); - - function guard(input: unknown): input is Parse { - return validateNode(ast, input); - } - - function parse(input: unknown): Parse { - if (!guard(input)) { - throw new TypeError(`Value does not match schema: ${source}`); - } - return input; - } - - return { source, ast, guard, parse }; -} - -// === Re-exports === - -export type { Parse } from './parse.ts'; -export type { - ArrayNode, - BooleanNode, - IntegerNode, - LiteralFalseNode, - LiteralNullNode, - LiteralStringNode, - LiteralTrueNode, - LiteralUndefinedNode, - NeverNode, - NumberNode, - ObjectNode, - PropertyNode, - RecordNode, - RefNode, - SchemaNode, - StringNode, - UnionNode, - UnknownNode, -} from './runtime.ts'; -export { parseSchema, validateNode } from './runtime.ts'; -export { nodeToJsonSchema } from './to-json-schema.ts'; -export type { SchemaDefinition }; -export { schema }; diff --git a/src/core/schema-dsl/parse.ts b/src/core/schema-dsl/parse.ts deleted file mode 100644 index b72cc2b8..00000000 --- a/src/core/schema-dsl/parse.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Compile-time schema string parser. - * - * Parses a string literal like `"{ name: string; age: number }"` into its - * corresponding TypeScript type at compile time using template literal types - * and recursive conditional inference. - * - * The compile-time parser and the runtime parser in `runtime.ts` implement - * the same grammar — the type-level version produces TypeScript types while - * the runtime version produces an AST. - * - * @module dreamcli/core/schema-dsl/parse - */ - -// === Whitespace handling === - -/** Whitespace characters recognized by the parser. */ -type WS = ' ' | '\n' | '\t' | '\r'; - -/** Strip leading whitespace. */ -type TrimLeft = T extends `${WS}${infer R}` ? TrimLeft : T; - -/** Strip trailing whitespace. */ -type TrimRight = T extends `${infer R}${WS}` ? TrimRight : T; - -/** Strip leading and trailing whitespace. */ -type Trim = TrimLeft>; - -// === Character counting for bracket balancing === - -/** - * Single-pass brace depth tracker. - * - * Walks the string character by character, pushing to the depth tuple - * on `{` and popping on `}`. Returns the final depth tuple — an empty - * tuple means balanced. Returns `false` on underflow (more `}` than `{`). - * - * This replaces the Chars + Keep + length approach, which materialized - * the entire string as a character tuple. Single-pass is O(n) recursion - * depth instead of O(3n). - */ -type TrackDepth = T extends `${infer C}${infer R}` - ? C extends '{' - ? TrackDepth - : C extends '}' - ? D extends [unknown, ...infer Rest] - ? TrackDepth - : false - : TrackDepth - : D; - -/** True when braces are balanced (depth returns to zero without underflow). */ -type Balanced = TrackDepth extends [] ? true : false; - -// === Balanced splitting === - -/** - * Naively split string T at every occurrence of delimiter D. - * Does not respect brace nesting — use {@link Rejoin} to rebalance. - */ -type RawSplit = T extends `${infer A}${D}${infer B}` - ? [A, ...RawSplit] - : [T]; - -/** - * Walk a tuple of fragments, merging adjacent entries whose cumulative - * brace count is unbalanced. Delimiter D is reinserted when merging. - * - * This is the bracket-balancing trick that makes nested-object parsing - * practical: split greedily, then glue back together where braces don't - * match. - */ -type Rejoin = T extends [ - infer A extends string, - infer B extends string, - ...infer R extends string[], -] - ? Balanced extends true - ? [A, ...Rejoin<[B, ...R], D>] - : Rejoin<[`${A}${D}${B}`, ...R], D> - : T; - -// === Filtering === - -/** Remove entries that are empty or whitespace-only after trimming. */ -type NonEmpty = T extends [infer H extends string, ...infer R extends string[]] - ? Trim extends '' - ? NonEmpty - : [H, ...NonEmpty] - : []; - -// === Top-level pipe detection === - -/** - * True when the string contains `|` outside of `{}` nesting. - * - * Splits at `|`, rebalances with {@link Rejoin}, and checks whether - * two or more fragments remain. If `|` was only inside braces, - * rebalancing collapses them back to one fragment. - */ -type HasTopLevelPipe = - Rejoin, '|'> extends [string, string, ...string[]] ? true : false; - -// === Flatten intersection into readable object type === - -/** Collapse an intersection of object types into a single flat object. */ -type Prettify = { [K in keyof T]: T[K] } & {}; - -// === Primitive mapping === - -/** Map a primitive type name to its TypeScript type. */ -type ParsePrimitive = T extends 'string' - ? string - : T extends 'number' - ? number - : T extends 'integer' - ? number - : T extends 'boolean' - ? boolean - : T extends 'true' - ? true - : T extends 'false' - ? false - : T extends 'null' - ? null - : T extends 'undefined' - ? undefined - : T extends 'unknown' - ? unknown - : T extends 'never' - ? never - : never; - -// === Union parsing === - -/** Parse each tuple entry as a value, producing a tuple of types. */ -type ParseEach = T extends [infer H extends string, ...infer R extends string[]] - ? [ParseValue, ...ParseEach] - : []; - -/** Collapse a tuple of types into a TypeScript union. */ -type TupleToUnion = T extends [infer H, ...infer R] - ? H | TupleToUnion - : never; - -/** Split at top-level `|`, parse each branch, collapse to union. */ -type ParseUnion = TupleToUnion< - ParseEach, '|'>>> ->; - -// === Value parsing === - -/** - * Parse a value type: object, union, array, literal, ref, Record, or primitive. - * - * Precedence (highest to lowest): - * 1. Object — `{...}` wrapping - * 2. Union — top-level `|` (outside braces) - * 3. Array — trailing `[]` suffix - * 4. String literal — `'...'` (maps to literal string type) - * 5. Reference — `@name` (maps to `unknown` at type level; resolved at runtime) - * 6. Record — `Record` (maps to `Record`) - * 7. Primitive — identifier lookup - * - * Union is checked before array so `string | number[]` parses as - * `string | number[]`, not `(string | number)[]`. - */ -type ParseValue> = V extends `{${infer Content}}` - ? Prettify> - : HasTopLevelPipe extends true - ? ParseUnion - : V extends `${infer Inner}[]` - ? ParseValue[] - : V extends `'${infer Literal}'` - ? Literal - : V extends `@${string}` - ? unknown - : V extends `Record<${string},${infer Val}>` - ? Record>> - : ParsePrimitive; - -// === Property parsing === - -/** - * Parse a single property string like `"name: string"` or `"age?: number"`. - * - * Matches the first `:` (leftmost), then checks whether the key ends - * with `?` to determine optionality. This avoids the trap of matching - * `?:` inside nested values. - */ -type ParseProperty = - Trim extends `${infer K}:${infer V}` - ? Trim extends `${infer Key}?` - ? { [P in Trim]?: ParseValue } - : { [P in Trim]: ParseValue } - : // biome-ignore lint/complexity/noBannedTypes: identity element for type-level intersection - {}; - -/** Recursively parse a tuple of property strings and intersect results. */ -type ParseProperties = T extends [ - infer H extends string, - ...infer R extends string[], -] - ? ParseProperty & ParseProperties - : // biome-ignore lint/complexity/noBannedTypes: identity element for type-level intersection - {}; - -// === Object parsing === - -/** - * Split content by `;` with bracket balancing, filter empties, - * parse each property, and intersect into a single object type. - */ -type ParseObject = Prettify< - ParseProperties, ';'>>> ->; - -// === Entry point === - -/** - * Parse a schema string literal into its TypeScript type. - * - * Supports primitives (`string`, `number`, `boolean`, `true`, `false`, - * `null`, `undefined`, `unknown`, `never`), arrays (`T[]`), objects - * (`{ key: T; ... }`), optional properties (`key?: T`), unions - * (`T | U`), and arbitrary nesting. - * - * @example - * ```ts - * type User = Parse<'{ name: string; age: number; tags?: string[] }'>; - * // ^? { name: string; age: number; tags?: string[] | undefined } - * - * type Status = Parse<'string | true'>; - * // ^? string | true - * ``` - */ -type Parse> = V extends `{${infer Content}}` - ? Prettify> - : ParseValue; - -export type { - Balanced, - HasTopLevelPipe, - NonEmpty, - Parse, - ParseEach, - ParseObject, - ParsePrimitive, - ParseProperties, - ParseProperty, - ParseUnion, - ParseValue, - Prettify, - RawSplit, - Rejoin, - TrackDepth, - Trim, - TrimLeft, - TrimRight, - TupleToUnion, - WS, -}; diff --git a/src/core/schema-dsl/runtime.ts b/src/core/schema-dsl/runtime.ts deleted file mode 100644 index acb8f707..00000000 --- a/src/core/schema-dsl/runtime.ts +++ /dev/null @@ -1,609 +0,0 @@ -/** - * Runtime schema string parser. - * - * Parses the same string literals as the compile-time {@link Parse} type, - * producing a runtime AST ({@link SchemaNode}) that mirrors the inferred - * TypeScript type. The AST can drive JSON Schema generation, validation, - * and introspection — all from the same source string. - * - * Grammar (matches the type-level parser in `parse.ts`): - * ``` - * value = union - * union = postfix ('|' postfix)* - * postfix = primary ('[]')* - * primary = object | record | ref | literal | IDENT - * object = '{' properties '}' - * properties = (property ';')* property? ';'? - * property = IDENT '?'? ':' value - * record = 'Record' '<' value ',' value '>' - * ref = '@' IDENT - * literal = "'" CHARS "'" - * ``` - * - * @module dreamcli/core/schema-dsl/runtime - */ - -// === AST node types (discriminated union) === - -/** Discriminated union of all schema AST nodes. */ -type SchemaNode = - | StringNode - | NumberNode - | IntegerNode - | BooleanNode - | LiteralTrueNode - | LiteralFalseNode - | LiteralNullNode - | LiteralStringNode - | LiteralUndefinedNode - | UnknownNode - | NeverNode - | ArrayNode - | ObjectNode - | UnionNode - | RefNode - | RecordNode; - -/** String primitive. */ -interface StringNode { - /** Identifies this node as a string type. */ - readonly kind: 'string'; -} - -/** Number primitive. */ -interface NumberNode { - /** Identifies this node as a number type. */ - readonly kind: 'number'; -} - -/** Integer primitive (maps to `number` in TS, `integer` in JSON Schema). */ -interface IntegerNode { - /** Identifies this node as an integer type. */ - readonly kind: 'integer'; -} - -/** Boolean primitive. */ -interface BooleanNode { - /** Identifies this node as a boolean type. */ - readonly kind: 'boolean'; -} - -/** Literal `true`. */ -interface LiteralTrueNode { - /** Identifies this node as the literal `true` value. */ - readonly kind: 'true'; -} - -/** Literal `false`. */ -interface LiteralFalseNode { - /** Identifies this node as the literal `false` value. */ - readonly kind: 'false'; -} - -/** Literal `null`. */ -interface LiteralNullNode { - /** Identifies this node as the literal `null` value. */ - readonly kind: 'null'; -} - -/** String literal value (e.g. `'confirm'`). */ -interface LiteralStringNode { - /** Identifies this node as a string literal. */ - readonly kind: 'literal'; - /** The exact string value this literal represents. */ - readonly value: string; -} - -/** Literal `undefined`. */ -interface LiteralUndefinedNode { - /** Identifies this node as the literal `undefined` value. */ - readonly kind: 'undefined'; -} - -/** The `unknown` top type. */ -interface UnknownNode { - /** Identifies this node as the `unknown` top type. */ - readonly kind: 'unknown'; -} - -/** The `never` bottom type. */ -interface NeverNode { - /** Identifies this node as the `never` bottom type. */ - readonly kind: 'never'; -} - -/** Array of element type. */ -interface ArrayNode { - /** Identifies this node as an array type. */ - readonly kind: 'array'; - /** The type of each array element. */ - readonly element: SchemaNode; -} - -/** Object with named properties. */ -interface ObjectNode { - /** Identifies this node as an object type. */ - readonly kind: 'object'; - /** Named properties keyed by field name. */ - readonly properties: Readonly>; -} - -/** Union of two or more member types. */ -interface UnionNode { - /** Identifies this node as a union type. */ - readonly kind: 'union'; - /** The constituent types of this union. */ - readonly members: readonly SchemaNode[]; -} - -/** Reference to a named definition (e.g. `@flag` → `$ref: '#/$defs/flag'`). */ -interface RefNode { - /** Identifies this node as a `$ref` reference. */ - readonly kind: 'ref'; - /** Name of the referenced definition (without the `@` prefix). */ - readonly target: string; -} - -/** Record/dictionary type (e.g. `Record`). */ -interface RecordNode { - /** Identifies this node as a record/dictionary type. */ - readonly kind: 'record'; - /** The type of each record value (keys are always strings). */ - readonly value: SchemaNode; -} - -/** A named property with optionality flag. */ -interface PropertyNode { - /** Whether the property is optional (`?` suffix in the schema DSL). */ - readonly optional: boolean; - /** The property's value type. */ - readonly schema: SchemaNode; -} - -// === Tokenizer === - -/** Token kind discriminator. */ -type TokenKind = - | 'ident' - | 'string_literal' - | 'ref' - | 'lbrace' - | 'rbrace' - | 'lbracket' - | 'rbracket' - | 'langle' - | 'rangle' - | 'comma' - | 'colon' - | 'semicolon' - | 'question' - | 'pipe'; - -/** A single token from the schema string. */ -interface Token { - readonly kind: TokenKind; - readonly value: string; -} - -/** Map single characters to their token kinds. */ -const SINGLE_CHAR_TOKENS: Readonly> = { - '{': 'lbrace', - '}': 'rbrace', - '[': 'lbracket', - ']': 'rbracket', - '<': 'langle', - '>': 'rangle', - ',': 'comma', - ':': 'colon', - ';': 'semicolon', - '?': 'question', - '|': 'pipe', -}; - -/** Whitespace characters to skip. */ -function isWhitespace(c: string): boolean { - return c === ' ' || c === '\n' || c === '\t' || c === '\r'; -} - -/** Identifier-valid characters (a-z, A-Z, 0-9, _, $). */ -function isIdentChar(c: string): boolean { - return ( - (c >= 'a' && c <= 'z') || - (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || - c === '_' || - c === '$' - ); -} - -/** Identifier start characters (a-z, A-Z, _, $). */ -function isIdentStart(c: string): boolean { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '$'; -} - -/** - * Tokenize a schema source string. - * - * Produces a flat array of tokens: identifiers (`string`, `number`, etc.), - * punctuation (`{`, `}`, `[`, `]`, `:`, `;`, `?`, `|`), and skips - * whitespace. Throws on unrecognized characters. - */ -function tokenize(source: string): Token[] { - const tokens: Token[] = []; - let i = 0; - - while (i < source.length) { - const c = source[i] ?? ''; - - if (isWhitespace(c)) { - i++; - continue; - } - - const singleKind = SINGLE_CHAR_TOKENS[c]; - if (singleKind !== undefined) { - tokens.push({ kind: singleKind, value: c }); - i++; - continue; - } - - // String literal: 'foo' - if (c === "'") { - let lit = ''; - i++; // skip opening quote - while (i < source.length && source[i] !== "'") { - lit += source[i]; - i++; - } - if (i >= source.length) throw new SyntaxError('Unterminated string literal'); - i++; // skip closing quote - tokens.push({ kind: 'string_literal', value: lit }); - continue; - } - - // Reference: @name - if (c === '@') { - i++; // skip @ - let ref = ''; - while (i < source.length) { - const ch = source[i] ?? ''; - if (!isIdentChar(ch)) break; - ref += ch; - i++; - } - if (ref.length === 0) - throw new SyntaxError(`Expected identifier after '@' at position ${String(i)}`); - tokens.push({ kind: 'ref', value: ref }); - continue; - } - - if (isIdentStart(c)) { - let ident = ''; - while (i < source.length) { - const ch = source[i] ?? ''; - if (!isIdentChar(ch)) break; - ident += ch; - i++; - } - tokens.push({ kind: 'ident', value: ident }); - continue; - } - - throw new SyntaxError(`Unexpected character '${c}' at position ${String(i)}`); - } - - return tokens; -} - -// === Primitive lookup === - -/** Primitive type names recognized by the parser. */ -const PRIMITIVES: ReadonlySet = new Set([ - 'string', - 'number', - 'integer', - 'boolean', - 'true', - 'false', - 'null', - 'undefined', - 'unknown', - 'never', -]); - -/** Map a primitive name to its AST node. */ -function primitiveNode(name: string): SchemaNode { - switch (name) { - case 'string': - return { kind: 'string' }; - case 'number': - return { kind: 'number' }; - case 'integer': - return { kind: 'integer' }; - case 'boolean': - return { kind: 'boolean' }; - case 'true': - return { kind: 'true' }; - case 'false': - return { kind: 'false' }; - case 'null': - return { kind: 'null' }; - case 'undefined': - return { kind: 'undefined' }; - case 'unknown': - return { kind: 'unknown' }; - case 'never': - return { kind: 'never' }; - default: - throw new SyntaxError(`Unknown type '${name}'`); - } -} - -// === Recursive descent parser === - -/** - * Parse a token stream into a {@link SchemaNode} AST. - * - * Implements the grammar documented at the module level using standard - * recursive descent. Each grammar rule maps to one `parse*` method. - */ -class SchemaParser { - private pos = 0; - - constructor(private readonly tokens: readonly Token[]) {} - - /** Parse the full token stream, asserting all tokens are consumed. */ - parseRoot(): SchemaNode { - const result = this.parseValue(); - if (this.pos < this.tokens.length) { - const leftover = this.peek(); - throw new SyntaxError( - `Unexpected token '${leftover?.value ?? '?'}' at position ${String(this.pos)}`, - ); - } - return result; - } - - // --- Grammar rules --- - - /** value = union */ - private parseValue(): SchemaNode { - return this.parseUnion(); - } - - /** union = postfix ('|' postfix)* */ - private parseUnion(): SchemaNode { - const first = this.parsePostfix(); - - if (!this.check('pipe')) return first; - - const members: SchemaNode[] = [first]; - while (this.match('pipe')) { - members.push(this.parsePostfix()); - } - return { kind: 'union', members }; - } - - /** postfix = primary ('[]')* */ - private parsePostfix(): SchemaNode { - let node = this.parsePrimary(); - - while (this.check('lbracket') && this.checkAt(this.pos + 1, 'rbracket')) { - this.advance(); // [ - this.advance(); // ] - node = { kind: 'array', element: node }; - } - - return node; - } - - /** primary = object | record | ref | literal | IDENT */ - private parsePrimary(): SchemaNode { - if (this.check('lbrace')) { - return this.parseObject(); - } - - if (this.check('string_literal')) { - const token = this.advance(); - return { kind: 'literal', value: token.value }; - } - - if (this.check('ref')) { - const token = this.advance(); - return { kind: 'ref', target: token.value }; - } - - const name = this.expectIdent(); - - // Record - if (name === 'Record') { - this.expect('langle'); - const keySchema = this.parseValue(); - if (keySchema.kind !== 'string') { - throw new SyntaxError( - "Record key type must be 'string' because JSON object keys are always strings", - ); - } - this.expect('comma'); - const valueSchema = this.parseValue(); - this.expect('rangle'); - return { kind: 'record', value: valueSchema }; - } - - if (!PRIMITIVES.has(name)) { - throw new SyntaxError(`Unknown type '${name}' at position ${String(this.pos - 1)}`); - } - return primitiveNode(name); - } - - /** object = '{' properties '}' */ - private parseObject(): ObjectNode { - this.expect('lbrace'); - - const properties: Record = {}; - - while (!this.check('rbrace')) { - const name = this.expectIdent(); - if (Object.hasOwn(properties, name)) { - throw new SyntaxError(`Duplicate property '${name}' at position ${String(this.pos - 1)}`); - } - const optional = this.match('question'); - this.expect('colon'); - const schema = this.parseValue(); - - properties[name] = { optional, schema }; - - this.match('semicolon'); // optional trailing semicolon - } - - this.expect('rbrace'); - return { kind: 'object', properties }; - } - - // --- Token helpers --- - - private peek(): Token | undefined { - return this.tokens[this.pos]; - } - - private check(kind: TokenKind): boolean { - return this.peek()?.kind === kind; - } - - private checkAt(pos: number, kind: TokenKind): boolean { - return this.tokens[pos]?.kind === kind; - } - - private advance(): Token { - const token = this.tokens[this.pos]; - if (token === undefined) { - throw new SyntaxError('Unexpected end of input'); - } - this.pos++; - return token; - } - - private match(kind: TokenKind): boolean { - if (this.check(kind)) { - this.pos++; - return true; - } - return false; - } - - private expect(kind: TokenKind): Token { - if (!this.check(kind)) { - const got = this.peek(); - throw new SyntaxError( - got - ? `Expected '${kind}' but got '${got.kind}' ('${got.value}') at position ${String(this.pos)}` - : `Expected '${kind}' but reached end of input`, - ); - } - return this.advance(); - } - - private expectIdent(): string { - return this.expect('ident').value; - } -} - -/** - * Parse a schema source string into a runtime AST. - * - * @param source - Schema string (e.g. `"{ name: string; age: number }"`). - * @returns The root {@link SchemaNode}. - * @throws {@link SyntaxError} on malformed input. - */ -function parseSchema(source: string): SchemaNode { - const tokens = tokenize(source); - return new SchemaParser(tokens).parseRoot(); -} - -// === Validation === - -function isRecord(value: unknown): value is Readonly> { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Validate that `input` conforms to the given AST node. - * - * @param node - The schema AST to validate against. - * @param input - The value to check. - * @returns `true` if the value matches the schema. - */ -function validateNode(node: SchemaNode, input: unknown): boolean { - switch (node.kind) { - case 'string': - return typeof input === 'string'; - case 'number': - return typeof input === 'number'; - case 'integer': - return typeof input === 'number' && Number.isInteger(input); - case 'boolean': - return typeof input === 'boolean'; - case 'true': - return input === true; - case 'false': - return input === false; - case 'null': - return input === null; - case 'literal': - return input === node.value; - case 'undefined': - return input === undefined; - case 'unknown': - return true; - case 'never': - return false; - case 'ref': - return false; // fail closed until refs can be resolved against a definition map - case 'array': - return Array.isArray(input) && input.every((el) => validateNode(node.element, el)); - case 'object': { - if (!isRecord(input)) return false; - for (const key of Object.keys(input)) { - if (!Object.hasOwn(node.properties, key)) return false; - } - for (const [key, prop] of Object.entries(node.properties)) { - if (!Object.hasOwn(input, key)) { - if (!prop.optional) return false; - continue; - } - if (!validateNode(prop.schema, input[key])) return false; - } - return true; - } - case 'record': { - if (!isRecord(input)) return false; - return Object.values(input).every((v) => validateNode(node.value, v)); - } - case 'union': - return node.members.some((member) => validateNode(member, input)); - } -} - -// === Exports === - -export type { - ArrayNode, - BooleanNode, - IntegerNode, - LiteralFalseNode, - LiteralNullNode, - LiteralStringNode, - LiteralTrueNode, - LiteralUndefinedNode, - NeverNode, - NumberNode, - ObjectNode, - PropertyNode, - RecordNode, - RefNode, - SchemaNode, - StringNode, - UnionNode, - UnknownNode, -}; -export { parseSchema, validateNode }; diff --git a/src/core/schema-dsl/schema-dsl.test.ts b/src/core/schema-dsl/schema-dsl.test.ts deleted file mode 100644 index 858049e1..00000000 --- a/src/core/schema-dsl/schema-dsl.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { nodeToJsonSchema, schema } from './index.ts'; -import type { Parse } from './parse.ts'; -import type { SchemaNode } from './runtime.ts'; -import { parseSchema, validateNode } from './runtime.ts'; - -// ── Compile-time type parser ──────────────────────────────────────── - -describe('Parse — compile-time type parser', () => { - // --- Primitives --- - - it('parses string', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses number', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses boolean', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses literal true', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses literal false', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses null', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses unknown', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - // --- Arrays --- - - it('parses string[]', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('parses number[][]', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - // --- Objects --- - - it('parses flat object', () => { - expectTypeOf>().toEqualTypeOf<{ - name: string; - age: number; - }>(); - }); - - it('parses object with optional property', () => { - type Result = Parse<'{ name: string; bio?: string }'>; - expectTypeOf().toEqualTypeOf<{ name: string; bio?: string }>(); - }); - - it('parses nested object', () => { - type Result = Parse<'{ user: { name: string; age: number } }'>; - expectTypeOf().toEqualTypeOf<{ user: { name: string; age: number } }>(); - }); - - it('parses object with array property', () => { - type Result = Parse<'{ tags: string[]; scores: number[] }'>; - expectTypeOf().toEqualTypeOf<{ tags: string[]; scores: number[] }>(); - }); - - it('handles trailing semicolon', () => { - type Result = Parse<'{ name: string; }'>; - expectTypeOf().toEqualTypeOf<{ name: string }>(); - }); - - // --- Unions --- - - it('parses two-member union', () => { - type Result = Parse<'string | number'>; - expectTypeOf().toEqualTypeOf(); - }); - - it('parses union with literal true', () => { - type Result = Parse<'string | true'>; - expectTypeOf().toEqualTypeOf(); - }); - - it('parses union with array (array binds tighter)', () => { - type Result = Parse<'string | number[]'>; - expectTypeOf().toEqualTypeOf(); - }); - - // --- Whitespace tolerance --- - - it('handles extra whitespace', () => { - type Result = Parse<' { name : string ; age : number } '>; - expectTypeOf().toEqualTypeOf<{ name: string; age: number }>(); - }); - - // --- Complex combinations --- - - it('parses deeply nested structure', () => { - type Result = Parse<'{ user: { profile: { email: string; age: number }; tags?: string[] } }'>; - expectTypeOf().toEqualTypeOf<{ - user: { profile: { email: string; age: number }; tags?: string[] }; - }>(); - }); - - it('parses object array', () => { - type Result = Parse<'{ name: string }[]'>; - expectTypeOf().toEqualTypeOf<{ name: string }[]>(); - }); - - it('parses union inside object property', () => { - type Result = Parse<'{ deprecated?: string | true }'>; - expectTypeOf().toEqualTypeOf<{ deprecated?: string | true }>(); - }); -}); - -// ── Runtime parser ────────────────────────────────────────────────── - -describe('parseSchema — runtime parser', () => { - // --- Primitives --- - - it('parses string', () => { - expect(parseSchema('string')).toEqual({ kind: 'string' }); - }); - - it('parses number', () => { - expect(parseSchema('number')).toEqual({ kind: 'number' }); - }); - - it('parses boolean', () => { - expect(parseSchema('boolean')).toEqual({ kind: 'boolean' }); - }); - - it('parses literal true', () => { - expect(parseSchema('true')).toEqual({ kind: 'true' }); - }); - - it('parses literal false', () => { - expect(parseSchema('false')).toEqual({ kind: 'false' }); - }); - - it('parses null', () => { - expect(parseSchema('null')).toEqual({ kind: 'null' }); - }); - - it('parses unknown', () => { - expect(parseSchema('unknown')).toEqual({ kind: 'unknown' }); - }); - - // --- Arrays --- - - it('parses string[]', () => { - expect(parseSchema('string[]')).toEqual({ - kind: 'array', - element: { kind: 'string' }, - }); - }); - - it('parses number[][]', () => { - expect(parseSchema('number[][]')).toEqual({ - kind: 'array', - element: { kind: 'array', element: { kind: 'number' } }, - }); - }); - - // --- Objects --- - - it('parses flat object', () => { - const ast = parseSchema('{ name: string; age: number }'); - expect(ast).toEqual({ - kind: 'object', - properties: { - name: { optional: false, schema: { kind: 'string' } }, - age: { optional: false, schema: { kind: 'number' } }, - }, - }); - }); - - it('parses optional property', () => { - const ast = parseSchema('{ bio?: string }'); - expect(ast).toEqual({ - kind: 'object', - properties: { - bio: { optional: true, schema: { kind: 'string' } }, - }, - }); - }); - - it('parses nested object', () => { - const ast = parseSchema('{ user: { name: string } }'); - expect(ast).toEqual({ - kind: 'object', - properties: { - user: { - optional: false, - schema: { - kind: 'object', - properties: { - name: { optional: false, schema: { kind: 'string' } }, - }, - }, - }, - }, - }); - }); - - // --- Unions --- - - it('parses union', () => { - expect(parseSchema('string | number')).toEqual({ - kind: 'union', - members: [{ kind: 'string' }, { kind: 'number' }], - }); - }); - - it('parses union with literal true', () => { - expect(parseSchema('string | true')).toEqual({ - kind: 'union', - members: [{ kind: 'string' }, { kind: 'true' }], - }); - }); - - it('parses union where array binds tighter than pipe', () => { - expect(parseSchema('string | number[]')).toEqual({ - kind: 'union', - members: [{ kind: 'string' }, { kind: 'array', element: { kind: 'number' } }], - }); - }); - - // --- Complex --- - - it('parses object array', () => { - expect(parseSchema('{ name: string }[]')).toEqual({ - kind: 'array', - element: { - kind: 'object', - properties: { - name: { optional: false, schema: { kind: 'string' } }, - }, - }, - }); - }); - - it('parses union inside object property', () => { - const ast = parseSchema('{ deprecated?: string | true }'); - expect(ast).toEqual({ - kind: 'object', - properties: { - deprecated: { - optional: true, - schema: { - kind: 'union', - members: [{ kind: 'string' }, { kind: 'true' }], - }, - }, - }, - }); - }); - - // --- Errors --- - - it('throws on unknown type', () => { - expect(() => parseSchema('Widget')).toThrow(SyntaxError); - }); - - it('throws on unexpected character', () => { - expect(() => parseSchema('string!')).toThrow(SyntaxError); - }); - - it('throws on duplicate object property names', () => { - expect(() => parseSchema('{ name: string; name: number }')).toThrow(SyntaxError); - }); - - it('throws when Record key type is not string', () => { - expect(() => parseSchema('Record')).toThrow( - "Record key type must be 'string' because JSON object keys are always strings", - ); - }); - - it('parses Record', () => { - expect(parseSchema('Record')).toEqual({ - kind: 'record', - value: { kind: 'number' }, - }); - }); -}); - -// ── Validation ────────────────────────────────────────────────────── - -describe('validateNode — runtime validation', () => { - it('validates string', () => { - const node: SchemaNode = { kind: 'string' }; - expect(validateNode(node, 'hello')).toBe(true); - expect(validateNode(node, 42)).toBe(false); - }); - - it('validates number', () => { - const node: SchemaNode = { kind: 'number' }; - expect(validateNode(node, 42)).toBe(true); - expect(validateNode(node, 'hello')).toBe(false); - }); - - it('validates literal true', () => { - const node: SchemaNode = { kind: 'true' }; - expect(validateNode(node, true)).toBe(true); - expect(validateNode(node, false)).toBe(false); - expect(validateNode(node, 'true')).toBe(false); - }); - - it('validates array', () => { - const node: SchemaNode = { kind: 'array', element: { kind: 'string' } }; - expect(validateNode(node, ['a', 'b'])).toBe(true); - expect(validateNode(node, [1])).toBe(false); - expect(validateNode(node, 'not-array')).toBe(false); - }); - - it('validates object with required and optional properties', () => { - const node: SchemaNode = { - kind: 'object', - properties: { - name: { optional: false, schema: { kind: 'string' } }, - bio: { optional: true, schema: { kind: 'string' } }, - }, - }; - expect(validateNode(node, { name: 'Alice' })).toBe(true); - expect(validateNode(node, { name: 'Alice', bio: 'dev' })).toBe(true); - expect(validateNode(node, { name: 'Alice', extra: 'unexpected' })).toBe(false); - expect(validateNode(node, {})).toBe(false); - expect(validateNode(node, { name: 42 })).toBe(false); - }); - - it('does not accept prototype-inherited required properties', () => { - const node: SchemaNode = { - kind: 'object', - properties: { - name: { optional: false, schema: { kind: 'string' } }, - }, - }; - const inherited: Record = {}; - Object.setPrototypeOf(inherited, { name: 'Alice' }); - expect(validateNode(node, inherited)).toBe(false); - }); - - it('validates union', () => { - const node: SchemaNode = { - kind: 'union', - members: [{ kind: 'string' }, { kind: 'true' }], - }; - expect(validateNode(node, 'hello')).toBe(true); - expect(validateNode(node, true)).toBe(true); - expect(validateNode(node, false)).toBe(false); - expect(validateNode(node, 42)).toBe(false); - }); - - it('fails unresolved refs closed', () => { - const node: SchemaNode = { kind: 'ref', target: 'flag' }; - expect(validateNode(node, 42)).toBe(false); - expect(validateNode(node, { anything: 'goes' })).toBe(false); - }); -}); - -// ── JSON Schema conversion ────────────────────────────────────────── - -describe('nodeToJsonSchema — JSON Schema conversion', () => { - it('throws on undefined nodes', () => { - expect(() => nodeToJsonSchema({ kind: 'undefined' })).toThrow( - "Cannot convert 'undefined' type to JSON Schema; model optionality at the parent level", - ); - }); -}); - -// ── Integration — schema() ties both layers together ──────────────── - -describe('schema() — integrated compile-time + runtime', () => { - it('infers flat object type and validates at runtime', () => { - const user = schema('{ name: string; age: number }'); - - // Compile-time: the parse return type is { name: string; age: number } - const data = user.parse({ name: 'Alice', age: 30 }); - expectTypeOf(data).toEqualTypeOf<{ name: string; age: number }>(); - expect(data.name).toBe('Alice'); - expect(data.age).toBe(30); - }); - - it('infers optional properties', () => { - const s = schema('{ name: string; bio?: string }'); - const data = s.parse({ name: 'Bob' }); - expectTypeOf(data).toEqualTypeOf<{ name: string; bio?: string }>(); - expect(data.name).toBe('Bob'); - }); - - it('infers union type (string | true)', () => { - const s = schema('string | true'); - expect(s.guard('hello')).toBe(true); - expect(s.guard(true)).toBe(true); - expect(s.guard(false)).toBe(false); - }); - - it('throws on invalid input', () => { - const s = schema('{ name: string }'); - expect(() => s.parse({ name: 42 })).toThrow(TypeError); - }); - - it('guard narrows type', () => { - const s = schema('{ value: number }'); - const input: unknown = { value: 5 }; - - if (s.guard(input)) { - expectTypeOf(input).toEqualTypeOf<{ value: number }>(); - expect(input.value).toBe(5); - } - }); - - it('rejects unresolved refs at runtime', () => { - const s = schema('@flag'); - expect(s.guard(42)).toBe(false); - expect(() => s.parse(42)).toThrow(TypeError); - }); -}); diff --git a/src/core/schema-dsl/to-json-schema.ts b/src/core/schema-dsl/to-json-schema.ts deleted file mode 100644 index 87016660..00000000 --- a/src/core/schema-dsl/to-json-schema.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Convert schema DSL AST nodes to JSON Schema (draft 2020-12) fragments. - * - * This bridges the schema DSL and JSON Schema generation: define a shape - * once as a DSL string, parse it to an AST, then convert to JSON Schema - * properties — no manual override maps or probe fixtures needed. - * - * @module dreamcli/core/schema-dsl/to-json-schema - */ - -import type { SchemaNode } from './runtime.ts'; - -/** - * Convert a {@link SchemaNode} AST node to a JSON Schema fragment. - * - * Mapping: - * - `string` → `{ type: 'string' }` - * - `number` → `{ type: 'number' }` - * - `integer` → `{ type: 'integer' }` - * - `boolean` → `{ type: 'boolean' }` - * - `true` → `{ const: true }` - * - `false` → `{ const: false }` - * - `null` → `{ type: 'null' }` - * - `undefined` → throws (JSON Schema has no `undefined` type) - * - `unknown` → `{}` (accepts any value) - * - `never` → `{ not: {} }` (rejects all values) - * - `'literal'` → `{ const: 'literal' }` - * - `@ref` → `{ $ref: '#/$defs/ref' }` - * - `T[]` → `{ type: 'array', items: convert(T) }` - * - `Record` → `{ type: 'object', additionalProperties: convert(V) }` - * - `{...}` → `{ type: 'object', properties: ..., required: [...] }` - * - `A | B` → `{ enum: [...] }` if all literals, else `{ oneOf: [...] }` - * - * @param node - The AST node to convert. - * @returns A plain JSON Schema fragment. - */ -function nodeToJsonSchema(node: SchemaNode): Record { - switch (node.kind) { - case 'string': - return { type: 'string' }; - case 'number': - return { type: 'number' }; - case 'integer': - return { type: 'integer' }; - case 'boolean': - return { type: 'boolean' }; - case 'true': - return { const: true }; - case 'false': - return { const: false }; - case 'null': - return { type: 'null' }; - case 'literal': - return { const: node.value }; - case 'undefined': - throw new Error( - "Cannot convert 'undefined' type to JSON Schema; model optionality at the parent level", - ); - case 'unknown': - return {}; - case 'never': - return { not: {} }; - case 'ref': - return { $ref: `#/$defs/${node.target}` }; - case 'array': - return { type: 'array', items: nodeToJsonSchema(node.element) }; - case 'record': - return { type: 'object', additionalProperties: nodeToJsonSchema(node.value) }; - case 'object': { - const properties: Record> = {}; - const required: string[] = []; - for (const [key, prop] of Object.entries(node.properties)) { - properties[key] = nodeToJsonSchema(prop.schema); - if (!prop.optional) required.push(key); - } - const result: Record = { - type: 'object', - additionalProperties: false, - properties, - }; - if (required.length > 0) result.required = required; - return result; - } - case 'union': { - // Union of all string literals → { enum: [...] } - const literalValues: string[] = []; - let allLiteral = true; - for (const member of node.members) { - if (member.kind === 'literal') { - literalValues.push(member.value); - } else { - allLiteral = false; - } - } - if (allLiteral) return { enum: literalValues }; - // Mixed union → oneOf - return { oneOf: node.members.map(nodeToJsonSchema) }; - } - } -} - -export { nodeToJsonSchema }; diff --git a/src/core/schema/zod-kinds.ts b/src/core/schema/zod-kinds.ts new file mode 100644 index 00000000..80db4e57 --- /dev/null +++ b/src/core/schema/zod-kinds.ts @@ -0,0 +1,84 @@ +/** + * Kind → zod schema derivation. + * + * Derives the canonical **declared-type** zod schema for a flag/arg from its + * runtime descriptor. zod is the single validation substrate: this schema + * drives JSON Schema generation (`z.toJSONSchema`) and backs runtime validation + * in the parse and resolve coercion engines. + * + * The schema is derived on demand (pure function of `kind` + `enumValues` + + * `elementSchema`) rather than stored on the descriptor, so flag/arg schemas + * remain plain, structurally-comparable, JSON-serialisable objects. + * + * Source-specific coercion (comma-splitting arrays, boolean word lists, env/ + * config string handling) is layered on top by the individual engines; this + * module only models the resolved value type. + * + * @module dreamcli/core/schema/zod-kinds + * @internal + */ + +import { z } from 'zod'; +import type { ArgSchema } from './arg.ts'; +import type { FlagSchema } from './flag.ts'; + +/** Options influencing the constructed schema for compound/constrained kinds. */ +interface ZodKindOptions { + /** Allowed literals when `kind === 'enum'`. */ + readonly enumValues?: readonly string[] | undefined; + /** Element schema when `kind === 'array'`. */ + readonly elementZod?: z.ZodType | undefined; +} + +/** + * Build the canonical declared-type zod schema for a flag/arg kind. + * + * - `string` → `z.string()` + * - `number` → `z.number()` (rejects `NaN`) + * - `boolean` → `z.boolean()` + * - `enum` → `z.enum(values)` (falls back to `z.string()` when no values yet) + * - `array` → `z.array(element)` (element defaults to `z.unknown()`) + * - `custom` → `z.unknown()` (opaque; refined by the property's parse function) + * + * @param kind - Flag or arg kind discriminator. + * @param options - Enum values / element schema for compound kinds. + * @returns A zod schema describing the kind's resolved value type. + */ +function buildZodSchema(kind: string, options: ZodKindOptions = {}): z.ZodType { + switch (kind) { + case 'string': + return z.string(); + case 'number': + return z.number(); + case 'boolean': + return z.boolean(); + case 'enum': { + const values = options.enumValues; + return values !== undefined && values.length > 0 + ? z.enum([...values] as [string, ...string[]]) + : z.string(); + } + case 'array': + return z.array(options.elementZod ?? z.unknown()); + case 'custom': + return z.unknown(); + default: + return z.unknown(); + } +} + +/** Derive the declared-type zod schema for a flag (recurses into array elements). */ +function flagZod(schema: FlagSchema): z.ZodType { + return buildZodSchema(schema.kind, { + enumValues: schema.enumValues, + elementZod: schema.elementSchema !== undefined ? flagZod(schema.elementSchema) : undefined, + }); +} + +/** Derive the declared-type zod schema for a positional arg. */ +function argZod(schema: ArgSchema): z.ZodType { + return buildZodSchema(schema.kind, { enumValues: schema.enumValues }); +} + +export type { ZodKindOptions }; +export { argZod, buildZodSchema, flagZod }; From 16deb703f775f24d132779911a7ed6392832bc75 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 7 Jun 2026 21:47:49 +0000 Subject: [PATCH 2/3] refactor: apply zod-review refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address findings from a Zod-4 best-practices review of the migration: - packaging: move zod from `dependencies` to `peerDependencies` + a `devDependency`, and widen the range to `^4.0.0` (the `^3.25 || ^4` dual range is impossible — z.toJSONSchema/z.registry are v4-only). Consumers now dedupe a single zod copy; publint/attw verified. - json-schema: stop the unconditional `anyOf`→`oneOf` rewrite in normalizeDefFragment (anyOf is correct for disjoint unions; oneOf was a latent semantic-narrowing bug). Drop z.int safe-integer bounds via the z.toJSONSchema `override` hook instead of matching hard-coded sentinels. - parse: return raw directly for the string kind (argv values are already strings) — removes a no-op `.parse()` that was the only throwing call with no ParseError catch net (latent raw-ZodError leak). - package-json: replace `z.custom(isRecord).refine(...)` bin validation with a native `z.record(z.string(), z.string())`. - style: `import * as z from 'zod'` across all zod consumers. - tests: pin the `#/$defs/` cross-reference shape and the anyOf/integer conventions in the definition meta-schema. Verified: typecheck, lint, format, meta-descriptions:check, 2207 tests, tsdown build + attw + publint all green. --- AGENTS.md | 6 ++-- GOALS.md | 4 +-- bun.lock | 7 ++-- package.json | 9 ++--- src/core/config/index.ts | 2 +- src/core/config/package-json.ts | 10 +++--- src/core/json-schema/index.ts | 41 +++++++++++----------- src/core/json-schema/json-schema.test.ts | 43 ++++++++++++++++++++++++ src/core/parse/index.ts | 6 ++-- src/core/schema/zod-kinds.ts | 2 +- 10 files changed, 89 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 32badf31..618b7c32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ ## OVERVIEW -Schema-first, fully typed TypeScript CLI framework. `zod` is the sole runtime dependency — the -schema/validation substrate behind parsing, coercion, and JSON Schema generation. In-repo exports point at +Schema-first, fully typed TypeScript CLI framework. `zod` is the sole dependency — declared as a peer +dependency — the schema/validation substrate behind parsing, coercion, and JSON Schema generation. In-repo exports point at `src/*.ts`; published Node defaults point at `dist/*.mjs`, while Bun and Deno keep source exports. Read `@DISCOVERIES.md` before planning, editing, or running task workflows. @@ -80,7 +80,7 @@ specs/ # planning/design docs ## ANTI-PATTERNS (THIS PROJECT) -- Keep runtime deps minimal — `zod` is the only one; justify any further addition +- Keep dependencies minimal — `zod` (a peer dependency) is the only one; justify any further addition - Do not use `process.*` or runtime-specific APIs in `src/core/` - Do not import through barrels when it would create cycles; direct-file imports are intentional in `cli/`, `completion/`, `output/`, `prompt/`, `resolve/`, and `runtime/` diff --git a/GOALS.md b/GOALS.md index 54a04301..13e48328 100644 --- a/GOALS.md +++ b/GOALS.md @@ -350,8 +350,8 @@ Adapters: ### 8) Non-functional requirements - **TypeScript ergonomics:** avoid type explosions that make TS slow or unreadable in editor hovers. -- **Small dependency footprint:** lean core with `zod` as the sole runtime dependency (the - schema/validation substrate); optional extras behind adapters/plugins. +- **Small dependency footprint:** lean core with `zod` as the sole dependency (a peer dependency; + the schema/validation substrate); optional extras behind adapters/plugins. - **Tree-shakeable ESM:** modern packaging with ESM-only exports and clear defaults. - **Deterministic tests:** no reliance on wall-clock time, real filesystem, or actual TTY unless explicitly integrated. diff --git a/bun.lock b/bun.lock index 4f3b6642..bb5c5c32 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,6 @@ "workspaces": { "": { "name": "@kjanat/dreamcli", - "dependencies": { - "zod": "4.4.3", - }, "devDependencies": { "@arethetypeswrong/core": "^0.18.2", "@biomejs/biome": "^2.4.10", @@ -31,6 +28,10 @@ "vitest": "^4.1.2", "wrangler": "^4.80.0", "yaml": "^2.8.3", + "zod": "^4.0.0", + }, + "peerDependencies": { + "zod": "^4.0.0", }, }, "examples/gh": { diff --git a/package.json b/package.json index 5dd5a837..36954089 100644 --- a/package.json +++ b/package.json @@ -105,9 +105,6 @@ "overrides": { "vitepress": "next" }, - "dependencies": { - "zod": "^4.4.3" - }, "devDependencies": { "@arethetypeswrong/core": "^0.18.2", "@biomejs/biome": "^2.4.10", @@ -131,7 +128,11 @@ "vitepress": "^2.0.0-alpha.17", "vitest": "^4.1.2", "wrangler": "^4.80.0", - "yaml": "^2.8.3" + "yaml": "^2.8.3", + "zod": "^4.0.0" + }, + "peerDependencies": { + "zod": "^4.0.0" }, "packageManager": "bun@1.3.12", "engines": { diff --git a/src/core/config/index.ts b/src/core/config/index.ts index f00690d2..90b3d017 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -11,7 +11,7 @@ * @module dreamcli/core/config */ -import { z } from 'zod'; +import * as z from 'zod'; import { CLIError } from '#internals/core/errors/index.ts'; import { isPlainObject } from '#internals/core/internal/guards.ts'; import type { RuntimeAdapter } from '#internals/runtime/adapter.ts'; diff --git a/src/core/config/package-json.ts b/src/core/config/package-json.ts index d4dbc1e2..639661f1 100644 --- a/src/core/config/package-json.ts +++ b/src/core/config/package-json.ts @@ -8,7 +8,7 @@ * @module dreamcli/core/config/package-json */ -import { z } from 'zod'; +import * as z from 'zod'; import { isRecord } from '#internals/core/internal/guards.ts'; import type { RuntimeAdapter } from '#internals/runtime/adapter.ts'; @@ -27,11 +27,9 @@ import type { RuntimeAdapter } from '#internals/runtime/adapter.ts'; */ const binSchema = z.union([ z.string(), - // Object bin: must be a loose (non-array) object whose values are all - // strings; any non-string value drops the whole field (parse fails). - z - .custom>((v) => isRecord(v)) - .refine((v) => Object.values(v).every((entry) => typeof entry === 'string')), + // Object bin: a record of string→string; any non-string value drops the + // whole field (parse fails), matching the historical hand-checked extraction. + z.record(z.string(), z.string()), ]); /** @internal */ diff --git a/src/core/json-schema/index.ts b/src/core/json-schema/index.ts index 56bd4e12..4fb62e04 100644 --- a/src/core/json-schema/index.ts +++ b/src/core/json-schema/index.ts @@ -13,7 +13,7 @@ * @module dreamcli/core/json-schema */ -import { z } from 'zod'; +import * as z from 'zod'; import type { CLISchema } from '#internals/core/cli/index.ts'; import { isRecord } from '#internals/core/internal/guards.ts'; import { getFlagAliasNames } from '#internals/core/schema/flag.ts'; @@ -589,6 +589,11 @@ function getBranchCommandDiscriminator(branch: Record): string * `array`→`{type:'array',items:{...}}`, `custom`/`unknown`→`{}`. The only * post-processing is stripping zod's `$schema` key and attaching * `description` / `default` / `deprecated`. + * + * `flagZod` schemas are transform-free, so input and output JSON Schemas are + * identical — `z.toJSONSchema`'s default (output) direction is intentional and + * `{ io: 'input' }` is not needed (it would also drop the `additionalProperties: + * false` the envelope sets explicitly). */ function flagToJsonSchemaType(schema: FlagSchema): Record { const result = stripJsonSchemaMeta(z.toJSONSchema(flagZod(schema))); @@ -695,8 +700,11 @@ function isPlainJsonObject(value: object): value is Record { * - boolean literal (`{type:'boolean',const:X}`) → `{const:X}` * - `z.record(...)` (`{type:'object',propertyNames,additionalProperties}`) * → `{type:'object',additionalProperties:...}` (drop `propertyNames`) - * - `z.int()` safe-integer bounds → plain `{type:'integer'}` - * - `anyOf` → `oneOf` + * + * `z.int()` safe-integer bounds are dropped upstream via the `z.toJSONSchema` + * `override` hook (see {@link buildDefinitionMetaSchema}). Unions are left as + * zod emits them (`anyOf`) — `anyOf` is the correct semantics for the + * meta-schema's disjoint unions. * * Recurses through `properties`, `items`, `additionalProperties`, and * `oneOf` / `anyOf` member lists. Cross-references emitted by the registry as @@ -726,16 +734,6 @@ function normalizeDefFragment(value: unknown): unknown { return rest; } - // `z.int()` stamps safe-integer min/max bounds; the meta-schema models a - // plain integer. - if (node.type === 'integer') { - const SAFE = 9007199254740991; - if (node.minimum === -SAFE && node.maximum === SAFE) { - const { minimum: _min, maximum: _max, ...rest } = node; - return rest; - } - } - const result: Record = {}; for (const [key, child] of Object.entries(node)) { // `z.record(...)` adds `propertyNames`; the meta-schema only keeps @@ -752,11 +750,6 @@ function normalizeDefFragment(value: unknown): unknown { ); continue; } - // Normalize mixed unions to `oneOf` (matches the prior DSL output). - if (key === 'anyOf') { - result.oneOf = normalizeDefFragment(child); - continue; - } result[key] = normalizeDefFragment(child); } return result; @@ -963,7 +956,17 @@ function buildDefinitionMetaSchema(): Record { // Render every named schema (root + the six `$defs`) in one pass so all // cross-references resolve to `#/$defs/`, then normalize to the // meta-schema's conventions. - const registryOutput = z.toJSONSchema(registry, { uri: (id) => `#/$defs/${id}` }); + const registryOutput = z.toJSONSchema(registry, { + uri: (id) => `#/$defs/${id}`, + override: (ctx) => { + // `z.int()` stamps JS safe-integer min/max bounds; the meta-schema + // models a plain integer, so drop them at the source. + if (ctx.jsonSchema.type === 'integer') { + delete ctx.jsonSchema.minimum; + delete ctx.jsonSchema.maximum; + } + }, + }); const registrySchemas = isRecord(registryOutput.schemas) ? registryOutput.schemas : {}; const defs: Record = {}; for (const name of ['command', 'flag', 'arg', 'prompt', 'choice', 'example']) { diff --git a/src/core/json-schema/json-schema.test.ts b/src/core/json-schema/json-schema.test.ts index 47891141..54a20aaa 100644 --- a/src/core/json-schema/json-schema.test.ts +++ b/src/core/json-schema/json-schema.test.ts @@ -624,6 +624,49 @@ describe('generateSchema — definition metadata', () => { "The command invocation (e.g. `'deploy production --force'`).", ); }); + + it('emits internal `#/$defs/` cross-references', () => { + // The registry `uri` callback resolves all named schemas to internal + // JSON pointers; guard that shape so a future zod change can't silently + // switch to inlined or external refs. + expect(definitionMetaSchema).toHaveProperty( + ['properties', 'commands', 'items'], + { $ref: '#/$defs/command' }, + ); + expect(definitionMetaSchema).toHaveProperty( + ['$defs', 'command', 'properties', 'flags', 'additionalProperties'], + { $ref: '#/$defs/flag' }, + ); + expect(definitionMetaSchema).toHaveProperty( + ['$defs', 'command', 'properties', 'commands', 'items'], + { $ref: '#/$defs/command' }, + ); + expect(definitionMetaSchema).toHaveProperty( + ['$defs', 'command', 'properties', 'args', 'items'], + { $ref: '#/$defs/arg' }, + ); + }); + + it('emits unions as `anyOf` and integers without safe-integer bounds', () => { + // `deprecated: string | true` is a disjoint union — `anyOf` is the + // correct semantics (not `oneOf`). + expect(definitionMetaSchema).toHaveProperty( + ['$defs', 'flag', 'properties', 'deprecated', 'anyOf'], + [{ type: 'string' }, { const: true }], + ); + // `z.int()` bounds are stripped via the `override` hook. + expect(definitionMetaSchema).toHaveProperty( + ['$defs', 'prompt', 'properties', 'min', 'type'], + 'integer', + ); + expect(definitionMetaSchema).not.toHaveProperty([ + '$defs', + 'prompt', + 'properties', + 'min', + 'minimum', + ]); + }); }); // === generateInputSchema — JSON Schema validation diff --git a/src/core/parse/index.ts b/src/core/parse/index.ts index ad9c1737..28d1cf50 100644 --- a/src/core/parse/index.ts +++ b/src/core/parse/index.ts @@ -169,7 +169,8 @@ function coerceFlagValue( ): unknown { switch (schema.kind) { case 'string': - return buildZodSchema('string').parse(raw); + // argv values are already strings; no coercion or validation needed. + return raw; case 'number': { const n = Number(raw); @@ -257,7 +258,8 @@ function coerceFlagValue( function coerceArgValue(argName: string, raw: string, schema: ArgSchema): unknown { switch (schema.kind) { case 'string': - return buildZodSchema('string').parse(raw); + // argv values are already strings; no coercion or validation needed. + return raw; case 'number': { const n = Number(raw); diff --git a/src/core/schema/zod-kinds.ts b/src/core/schema/zod-kinds.ts index 80db4e57..50e4867d 100644 --- a/src/core/schema/zod-kinds.ts +++ b/src/core/schema/zod-kinds.ts @@ -18,7 +18,7 @@ * @internal */ -import { z } from 'zod'; +import * as z from 'zod'; import type { ArgSchema } from './arg.ts'; import type { FlagSchema } from './flag.ts'; From fecb012cbd7e47ac3cf0903c21e88dde6feb82c7 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 7 Jun 2026 22:13:51 +0000 Subject: [PATCH 3/3] fix: address CodeRabbit review on PR #19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - completion/shells/shared.ts: import createSchema/CommandSchema/FlagSchema from their defining modules instead of the schema barrel (barrel imports are banned in completion/ to avoid cycles). - parse/index.ts: reject empty enumValues in both enum branches — an empty array made buildZodSchema fall back to z.string() and accept anything; now fails fast as INVALID_SCHEMA, matching the resolve coercion engine. - json-schema/AGENTS.md: drop stale schema-DSL references (parseSchema/ nodeToJsonSchema); document the zod -> JSON Schema bridge instead. - CHANGELOG.md: fill the [Unreleased] section (Added/Changed/Removed) for the zod migration and schema-dsl removal. Verified: typecheck, lint, format, 2207 tests green. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ src/core/completion/shells/shared.ts | 5 +++-- src/core/json-schema/AGENTS.md | 15 +++++++-------- src/core/parse/index.ts | 8 ++++++-- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06440b77..dd672a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **zod validation substrate** — `zod` (`^4.0.0`) is now a peer dependency and the single + schema/validation engine. New internal `src/core/schema/zod-kinds.ts` derives a canonical + declared-type zod schema from each flag/arg kind (`buildZodSchema`, `flagZod`, `argZod`), + driving both JSON Schema generation and runtime coercion. +- **Shared structural guards** — `src/core/internal/guards.ts` consolidates the previously + duplicated `isRecord` / `isPlainObject` helpers. + +### Changed + +- **JSON Schema generation** — `generateInputSchema` and the definition meta-schema are now + produced via `z.toJSONSchema()` (zod registry + post-processing) instead of the hand-built + DSL bridge. Public output shape is preserved. +- **Coercion engines** — `parse` and `resolve` coercion now validate string/number/enum values + through zod, with all `ParseError` / `ValidationError` messages, codes, and details unchanged. +- **Config / package.json validation** — parsed external data is now validated with zod, + preserving exact failure semantics (`null` return vs `CONFIG_PARSE_ERROR`). + +### Removed + +- **Custom schema DSL** — removed `src/core/schema-dsl/` entirely (string-literal tokenizer, + parser, compile-time `Parse` types, AST validator, and AST→JSON-Schema converter). It was + internal-only and is fully superseded by zod. + ## [2.1.0] - 2026-04-16 ### Added diff --git a/src/core/completion/shells/shared.ts b/src/core/completion/shells/shared.ts index 69899ddc..06eafd3e 100644 --- a/src/core/completion/shells/shared.ts +++ b/src/core/completion/shells/shared.ts @@ -11,8 +11,9 @@ import { collectPropagatedFlags } from '#internals/core/cli/propagate.ts'; import { resolveRootSurface } from '#internals/core/cli/root-surface.ts'; -import { createSchema } from '#internals/core/schema/index.ts'; -import type { CommandSchema, FlagSchema } from '#internals/core/schema/index.ts'; +import type { CommandSchema } from '#internals/core/schema/command.ts'; +import { createSchema } from '#internals/core/schema/flag.ts'; +import type { FlagSchema } from '#internals/core/schema/flag.ts'; import { DREAMCLI_REVISION, DREAMCLI_VERSION } from '#internals/version.ts'; // --- Version tag for generated script headers diff --git a/src/core/json-schema/AGENTS.md b/src/core/json-schema/AGENTS.md index 1dc9e016..69edd869 100644 --- a/src/core/json-schema/AGENTS.md +++ b/src/core/json-schema/AGENTS.md @@ -3,8 +3,7 @@ ## OVERVIEW Single large module plus one generated companion file. It emits both DreamCLI definition metadata -and draft-2020-12 input schemas, and it bridges the string-literal schema DSL into JSON Schema -output. +and draft-2020-12 input schemas, deriving them from zod schemas via `z.toJSONSchema()`. ## FILES @@ -16,12 +15,12 @@ output. ## WHERE TO LOOK -| Task | Location | Notes | -| -------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| Change definition schema shape | `generateSchema()` | CLI tree, flags, args, examples, hidden and prompt filtering | -| Change input validation schema | `generateInputSchema()` | JSON Schema 2020-12 for config/editor use cases | -| Change DSL -> JSON Schema bridge | `parseSchema()`, `nodeToJsonSchema()` in `index.ts` | feeds custom and DSL flag shapes | -| Regenerate descriptions | `meta-descriptions.generated.ts`, `../../../scripts/build-meta-descriptions.ts` | script is source of truth | +| Task | Location | Notes | +| -------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Change definition schema shape | `generateSchema()` | CLI tree, flags, args, examples, hidden and prompt filtering | +| Change input validation schema | `generateInputSchema()` | JSON Schema 2020-12 for config/editor use cases | +| Change zod -> JSON Schema bridge | `flagZod()`/`argZod()` (`../schema/zod-kinds.ts`), `normalizeDefFragment()` in `index.ts` | derive flag/arg shapes via `z.toJSONSchema()` | +| Regenerate descriptions | `meta-descriptions.generated.ts`, `../../../scripts/build-meta-descriptions.ts` | script is source of truth | ## CONVENTIONS diff --git a/src/core/parse/index.ts b/src/core/parse/index.ts index 28d1cf50..3e47a24d 100644 --- a/src/core/parse/index.ts +++ b/src/core/parse/index.ts @@ -198,7 +198,9 @@ function coerceFlagValue( case 'enum': { const allowed = schema.enumValues; - if (allowed === undefined) { + // Empty enumValues would make buildZodSchema fall back to z.string() + // and accept anything — treat it as a misconfiguration, like resolve. + if (allowed === undefined || allowed.length === 0) { throw new ParseError( `Enum flag --${flagName} is misconfigured: no allowed values declared`, { @@ -275,7 +277,9 @@ function coerceArgValue(argName: string, raw: string, schema: ArgSchema): unknow case 'enum': { const allowed = schema.enumValues; - if (allowed === undefined) { + // Empty enumValues would make buildZodSchema fall back to z.string() + // and accept anything — treat it as a misconfiguration, like resolve. + if (allowed === undefined || allowed.length === 0) { throw new ParseError( `Enum argument <${argName}> is misconfigured: no allowed values declared`, {