mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 23:52:06 +00:00
Remove effect-zod bridge (#26956)
This commit is contained in:
@@ -1,370 +0,0 @@
|
||||
import { Effect, Option, Schema, SchemaAST } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
/**
|
||||
* Annotation key for providing a hand-crafted Zod schema that the walker
|
||||
* should use instead of re-deriving from the AST. Attach it via
|
||||
* `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`.
|
||||
*/
|
||||
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
|
||||
|
||||
// AST nodes are immutable and frequently shared across schemas (e.g. a single
|
||||
// Schema.Class embedded in multiple parents). Memoizing by node identity
|
||||
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
|
||||
// by reference across callers.
|
||||
const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
|
||||
|
||||
// Shared empty ParseOptions for the rare callers that need one — avoids
|
||||
// allocating a fresh object per parse inside refinements and transforms.
|
||||
const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
|
||||
|
||||
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a Zod value from an Effect Schema (or a Schema-backed export with a
|
||||
* `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
|
||||
* `.omit`, `.extend`, and friends are accessible.
|
||||
*
|
||||
* The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
|
||||
* to an object; this helper keeps the "I started from a `Schema.Struct`" cast
|
||||
* in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
|
||||
* call sites.
|
||||
*
|
||||
* The return is intentionally loose — carrying Schema field types through the
|
||||
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
|
||||
* explosions for branded primitives (`string & Brand<"SessionID">` extends
|
||||
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
|
||||
* mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
|
||||
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
|
||||
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
|
||||
*/
|
||||
export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
|
||||
const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
|
||||
return derived as unknown as z.ZodObject<any>
|
||||
}
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodTypeAny {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a JSON Schema for a tool/route parameter schema — derives the zod form
|
||||
* via the walker so Effect Schema inputs flow through the same zod-openapi
|
||||
* pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what
|
||||
* `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
|
||||
*/
|
||||
export function toJsonSchema<S extends Schema.Top>(schema: S) {
|
||||
return z.toJSONSchema(zod(schema), { io: "input" })
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const cached = walkCache.get(ast)
|
||||
if (cached) return cached
|
||||
const result = walkUncached(ast)
|
||||
walkCache.set(ast, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
|
||||
// `description` annotations layer on top of an override so callers can
|
||||
// reuse a shared override schema (e.g. `SessionID`) and still add a
|
||||
// per-field description on the outer wrapper.
|
||||
const base = override ?? bodyWithChecks(ast)
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const described = desc ? base.describe(desc) : base
|
||||
return ref ? described.meta({ ref }) : described
|
||||
}
|
||||
|
||||
function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
// Schema.Class wraps its fields in a Declaration AST plus an encoding that
|
||||
// constructs the class instance. For the Zod derivation we want the plain
|
||||
// field shape (the decoded/consumer view), not the class instance — so
|
||||
// Declarations fall through to body(), not encoded(). User-level
|
||||
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
|
||||
// nodes, where we do apply the transform.
|
||||
//
|
||||
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
|
||||
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
|
||||
// encoding resolves a default from Option.none() route through body()/opt().
|
||||
const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0)
|
||||
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
|
||||
const base = hasTransform ? encoded(ast) : body(ast)
|
||||
return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
||||
}
|
||||
|
||||
// Walk the encoded side and apply each link's decode to produce the decoded
|
||||
// shape. A node `Target` produced by `from.decodeTo(Target)` carries
|
||||
// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
|
||||
// nest the encoding via `Link.to` so walking it recursively threads all
|
||||
// prior transforms — typical encoding.length is 1.
|
||||
function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const encoding = ast.encoding!
|
||||
return encoding.reduce<z.ZodTypeAny>(
|
||||
(acc, link) => acc.transform((v) => decode(link.transformation, v)),
|
||||
walk(encoding[0].to),
|
||||
)
|
||||
}
|
||||
|
||||
// Transformations built via pure `SchemaGetter.transform(fn)` (the common
|
||||
// decodeTo case) resolve synchronously, so running with no services is safe.
|
||||
// Effectful / middleware-based transforms will surface as Effect defects.
|
||||
function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
|
||||
const exit = Effect.runSyncExit(
|
||||
(transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
|
||||
Option.Option<unknown>
|
||||
>,
|
||||
)
|
||||
if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
|
||||
return Option.getOrElse(exit.value, () => value)
|
||||
}
|
||||
|
||||
// Flatten FilterGroups and any nested variants into a linear list of Filters.
|
||||
// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
|
||||
// translated into native Zod methods so their JSON Schema output includes
|
||||
// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
|
||||
// Anything else falls back to a single .superRefine layer — runtime-only,
|
||||
// emits no JSON Schema constraint.
|
||||
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const filters: SchemaAST.Filter<unknown>[] = []
|
||||
const collect = (c: SchemaAST.Check<unknown>) => {
|
||||
if (c._tag === "FilterGroup") c.checks.forEach(collect)
|
||||
else filters.push(c)
|
||||
}
|
||||
checks.forEach(collect)
|
||||
|
||||
const unhandled: SchemaAST.Filter<unknown>[] = []
|
||||
const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
|
||||
const next = translateFilter(acc, filter)
|
||||
if (next) return next
|
||||
unhandled.push(filter)
|
||||
return acc
|
||||
}, out)
|
||||
|
||||
if (unhandled.length === 0) return translated
|
||||
|
||||
return translated.superRefine((value, ctx) => {
|
||||
for (const filter of unhandled) {
|
||||
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
|
||||
if (!issue) continue
|
||||
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
|
||||
ctx.addIssue({ code: "custom", message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Translate a well-known Effect Schema filter into a native Zod method call on
|
||||
// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
|
||||
// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
|
||||
// construction time. Returns `undefined` for unrecognised filters so the
|
||||
// caller can fall back to the generic .superRefine path.
|
||||
function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
|
||||
const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
|
||||
if (!meta || typeof meta._tag !== "string") return undefined
|
||||
switch (meta._tag) {
|
||||
case "isInt":
|
||||
return call(out, "int")
|
||||
case "isFinite":
|
||||
return call(out, "finite")
|
||||
case "isGreaterThan":
|
||||
return call(out, "gt", meta.exclusiveMinimum)
|
||||
case "isGreaterThanOrEqualTo":
|
||||
return call(out, "gte", meta.minimum)
|
||||
case "isLessThan":
|
||||
return call(out, "lt", meta.exclusiveMaximum)
|
||||
case "isLessThanOrEqualTo":
|
||||
return call(out, "lte", meta.maximum)
|
||||
case "isBetween": {
|
||||
const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
|
||||
if (!lo) return undefined
|
||||
return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
|
||||
}
|
||||
case "isMultipleOf":
|
||||
return call(out, "multipleOf", meta.divisor)
|
||||
case "isMinLength":
|
||||
return call(out, "min", meta.minLength)
|
||||
case "isMaxLength":
|
||||
return call(out, "max", meta.maxLength)
|
||||
case "isLengthBetween": {
|
||||
const lo = call(out, "min", meta.minimum)
|
||||
if (!lo) return undefined
|
||||
return call(lo, "max", meta.maximum)
|
||||
}
|
||||
case "isPattern":
|
||||
return call(out, "regex", meta.regExp)
|
||||
case "isStartsWith":
|
||||
return call(out, "startsWith", meta.startsWith)
|
||||
case "isEndsWith":
|
||||
return call(out, "endsWith", meta.endsWith)
|
||||
case "isIncludes":
|
||||
return call(out, "includes", meta.includes)
|
||||
case "isUUID":
|
||||
return call(out, "uuid")
|
||||
case "isULID":
|
||||
return call(out, "ulid")
|
||||
case "isBase64":
|
||||
return call(out, "base64")
|
||||
case "isBase64Url":
|
||||
return call(out, "base64url")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Invoke a named Zod method on `target` if it exists, otherwise return
|
||||
// undefined so the caller can fall back. Using this helper instead of a
|
||||
// typed cast keeps `translateFilter` free of per-case narrowing noise.
|
||||
function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
|
||||
const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
|
||||
return typeof fn === "function" ? fn.apply(target, args) : undefined
|
||||
}
|
||||
|
||||
function issueMessage(issue: any): string | undefined {
|
||||
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
|
||||
if (typeof issue?.message === "string") return issue.message
|
||||
return undefined
|
||||
}
|
||||
|
||||
function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (SchemaAST.isOptional(ast)) return opt(ast)
|
||||
|
||||
switch (ast._tag) {
|
||||
case "String":
|
||||
return z.string()
|
||||
case "Number":
|
||||
return z.number()
|
||||
case "Boolean":
|
||||
return z.boolean()
|
||||
case "Null":
|
||||
return z.null()
|
||||
case "Undefined":
|
||||
return z.undefined()
|
||||
case "Any":
|
||||
case "Unknown":
|
||||
return z.unknown()
|
||||
case "Never":
|
||||
return z.never()
|
||||
case "Literal":
|
||||
return z.literal(ast.literal)
|
||||
case "Union":
|
||||
return union(ast)
|
||||
case "Objects":
|
||||
return object(ast)
|
||||
case "Arrays":
|
||||
return array(ast)
|
||||
case "Declaration":
|
||||
return decl(ast)
|
||||
default:
|
||||
return fail(ast)
|
||||
}
|
||||
}
|
||||
|
||||
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (ast._tag !== "Union") return fail(ast)
|
||||
const items = ast.types.filter((item) => item._tag !== "Undefined")
|
||||
const inner =
|
||||
items.length === 1
|
||||
? walk(items[0])
|
||||
: items.length > 1
|
||||
? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
: z.undefined()
|
||||
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
|
||||
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
|
||||
// it to extract the default and emit `.default(...)` instead of `.optional()`.
|
||||
const fallback = extractDefault(ast)
|
||||
if (fallback !== undefined) return inner.default(fallback.value)
|
||||
return inner.optional()
|
||||
}
|
||||
|
||||
type DecodeLink = {
|
||||
readonly transformation: {
|
||||
readonly decode: {
|
||||
readonly run: (
|
||||
input: Option.Option<unknown>,
|
||||
options: SchemaAST.ParseOptions,
|
||||
) => Effect.Effect<Option.Option<unknown>, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
|
||||
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
|
||||
if (!encoding?.length) return undefined
|
||||
// Walk the chain of encoding Links in order; the first Getter that produces
|
||||
// a value from Option.none wins. withDecodingDefault always puts its
|
||||
// defaulting Link adjacent to the optional Union.
|
||||
for (const link of encoding) {
|
||||
const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
|
||||
if (probe._tag !== "Success") continue
|
||||
if (Option.isSome(probe.value)) return { value: probe.value.value }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
||||
// When every member is a string literal, emit z.enum() so that
|
||||
// JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }.
|
||||
if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) {
|
||||
return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]])
|
||||
}
|
||||
|
||||
const items = ast.types.map(walk)
|
||||
if (items.length === 1) return items[0]
|
||||
if (items.length < 2) return fail(ast)
|
||||
|
||||
const discriminator = ast.annotations?.discriminator
|
||||
if (typeof discriminator === "string") {
|
||||
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
|
||||
}
|
||||
|
||||
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
||||
// Pure record: { [k: string]: V }
|
||||
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z.record(z.string(), walk(sig.type))
|
||||
}
|
||||
|
||||
// Pure object with known fields and no index signatures.
|
||||
if (ast.indexSignatures.length === 0) {
|
||||
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
||||
}
|
||||
|
||||
// Struct with a catchall (StructWithRest): known fields + index signature.
|
||||
// Only supports a single string-keyed index signature; multi-signature or
|
||||
// symbol/number keys fall through to fail.
|
||||
if (ast.indexSignatures.length !== 1) return fail(ast)
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z
|
||||
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
|
||||
.catchall(walk(sig.type))
|
||||
}
|
||||
|
||||
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
||||
// Pure variadic arrays: { elements: [], rest: [item] }
|
||||
if (ast.elements.length === 0) {
|
||||
if (ast.rest.length !== 1) return fail(ast)
|
||||
return z.array(walk(ast.rest[0]))
|
||||
}
|
||||
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
|
||||
// Tuples with a variadic tail (...rest) are not yet supported.
|
||||
if (ast.rest.length > 0) return fail(ast)
|
||||
const items = ast.elements.map(walk)
|
||||
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
|
||||
if (ast.typeParameters.length !== 1) return fail(ast)
|
||||
return walk(ast.typeParameters[0])
|
||||
}
|
||||
|
||||
function fail(ast: SchemaAST.AST): never {
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { zod, ZodOverride } from "./effect-zod"
|
||||
|
||||
/**
|
||||
* Integer greater than zero.
|
||||
@@ -21,7 +20,6 @@ export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
|
||||
decode: SchemaGetter.passthrough({ strict: false }),
|
||||
encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
|
||||
}),
|
||||
Schema.annotate({ [ZodOverride]: zod(schema).optional() }),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,17 +57,9 @@ Rules:
|
||||
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
|
||||
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
|
||||
|
||||
## Schema → Zod interop
|
||||
## Schema boundaries
|
||||
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
|
||||
|
||||
```ts
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
See `Auth.ZodInfo` for the canonical example.
|
||||
Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion.
|
||||
|
||||
## InstanceState init patterns
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# Schema migration
|
||||
|
||||
Practical reference for migrating data types in `packages/opencode` from
|
||||
Zod-first definitions to Effect Schema with Zod compatibility shims.
|
||||
Zod-first definitions to Effect Schema.
|
||||
|
||||
## Goal
|
||||
|
||||
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
||||
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
||||
schema via `@opencode-ai/core/effect-zod`.
|
||||
outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and
|
||||
native JSON Schema generation at HTTP, tool, and AI SDK boundaries.
|
||||
|
||||
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
||||
moves to `@effect/platform`, every Schema-first DTO can flow through
|
||||
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
|
||||
`effect-zod` walker plus every `.zod` static can be deleted.
|
||||
The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should
|
||||
flow through `HttpApi` / `HttpRouter` without a Zod translation layer.
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
@@ -26,19 +23,16 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Boolean,
|
||||
}) {
|
||||
static readonly zod = zod(Info)
|
||||
}
|
||||
}) {}
|
||||
```
|
||||
|
||||
If the class cannot reference itself cleanly during initialization, use the
|
||||
two-step `withStatics` pattern:
|
||||
If a schema needs local static helpers, use the two-step `withStatics` pattern:
|
||||
|
||||
```ts
|
||||
export const Info = Schema.Struct({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) })))
|
||||
```
|
||||
|
||||
### Errors
|
||||
@@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
|
||||
|
||||
### IDs and branded leaf types
|
||||
|
||||
Keep branded/schema-backed IDs as Effect schemas and expose
|
||||
`static readonly zod` for compatibility when callers still expect Zod.
|
||||
Keep branded/schema-backed IDs as Effect schemas.
|
||||
|
||||
### Refinements
|
||||
|
||||
Reuse named refinements instead of re-spelling `z.number().int().positive()`
|
||||
in every schema. The `effect-zod` walker translates the Effect versions into
|
||||
the corresponding zod methods, so JSON Schema output (`type: integer`,
|
||||
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
|
||||
Reuse named refinements instead of re-spelling numeric or string constraints in
|
||||
every schema. Boundary JSON Schema helpers should normalize native Effect JSON
|
||||
Schema output only where a provider requires it.
|
||||
|
||||
```ts
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
@@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate
|
||||
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
```
|
||||
|
||||
See `test/util/effect-zod.test.ts` for the full set of translated checks.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
During migration, route validators, tool parameters, and any existing
|
||||
Zod-based boundary should consume the derived `.zod` schema instead of
|
||||
During migration, route validators, tool parameters, and AI SDK schemas should
|
||||
consume Effect schemas directly or use a narrow boundary helper. Avoid
|
||||
maintaining a second hand-written Zod schema.
|
||||
|
||||
The default should be:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- `.zod` exists only as a compatibility surface
|
||||
- new domain models should not start Zod-first unless there is a concrete
|
||||
boundary-specific need
|
||||
|
||||
@@ -89,27 +78,22 @@ The default should be:
|
||||
It is fine to keep a Zod-native schema temporarily when:
|
||||
|
||||
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
|
||||
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
|
||||
- the validator is part of an existing public API that explicitly accepts Zod
|
||||
- the migration would force unrelated churn across a large call graph
|
||||
|
||||
When this happens, prefer leaving a short note or TODO rather than silently
|
||||
creating a parallel schema source of truth.
|
||||
|
||||
## Escape hatches
|
||||
## Boundary helpers
|
||||
|
||||
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
|
||||
cases the pure-Schema path cannot express. Each one stays in the codebase
|
||||
only as long as its upstream or local dependency requires it — inline
|
||||
comments document when each can be deleted.
|
||||
Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge.
|
||||
|
||||
### `ZodOverride` annotation
|
||||
- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)`
|
||||
- Public config/TUI schemas: `packages/opencode/script/schema.ts`
|
||||
- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)`
|
||||
|
||||
Replaces the entire derivation with a hand-crafted zod schema. Used when:
|
||||
|
||||
- the target carries external `$ref` metadata (e.g.
|
||||
`config/model-id.ts` points at `https://models.dev/...`)
|
||||
- the target is a zod-only schema that cannot yet be expressed as Schema
|
||||
(e.g. `ConfigAgent.Info`, `Log.Level`)
|
||||
Plugin tools are the main remaining intentional Zod boundary because the public
|
||||
plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`.
|
||||
|
||||
### Local `DeepMutable<T>` in `config/config.ts`
|
||||
|
||||
@@ -133,7 +117,7 @@ Migrate in this order:
|
||||
2. Exported `Info`, `Input`, `Output`, and DTO types
|
||||
3. Tagged domain errors
|
||||
4. Service-local internal models
|
||||
5. Route and tool boundary validators that can switch to `.zod`
|
||||
5. Route and tool boundary validators that can switch to native Effect Schema helpers
|
||||
|
||||
This keeps shared types canonical first and makes boundary updates mostly
|
||||
mechanical.
|
||||
@@ -142,21 +126,18 @@ mechanical.
|
||||
|
||||
### `src/config/` ✅ complete
|
||||
|
||||
All of `packages/opencode/src/config/` has been migrated. Files that still
|
||||
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
|
||||
type annotations — the `export const <Info|Spec>` values are all Effect
|
||||
Schema at source.
|
||||
All of `packages/opencode/src/config/` has been migrated. The `export const
|
||||
<Info|Spec>` values are all Effect Schema at source.
|
||||
|
||||
A file is considered "done" when:
|
||||
|
||||
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
|
||||
are authored as Effect Schema
|
||||
- any remaining zod is either a derived compat bridge (via `zod()` /
|
||||
`zodObject()`), a `z.ZodType` type annotation, or a documented
|
||||
`ZodOverride` escape hatch — never a hand-written parallel source of truth
|
||||
- any remaining Zod is an explicit boundary compatibility choice, not a
|
||||
hand-written parallel source of truth
|
||||
|
||||
Files that meet this bar but still carry a compat bridge are checked off
|
||||
with an inline note describing the bridge and what unblocks its removal.
|
||||
Files that meet this bar but still carry a compatibility boundary are checked
|
||||
off with an inline note describing the boundary and what unblocks its removal.
|
||||
|
||||
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
|
||||
- [x] server, layout
|
||||
@@ -361,15 +342,8 @@ piecewise.
|
||||
- [ ] `src/util/update-schema.ts`
|
||||
- [ ] `src/worktree/index.ts`
|
||||
|
||||
### Do-not-migrate
|
||||
|
||||
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
|
||||
(it's what emits zod from Schema). Goes away only when the `.zod`
|
||||
compatibility layer is no longer needed anywhere.
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
|
||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||
Effect definitions for the same domain type.
|
||||
- Keep the migration incremental. Converting the domain model first is more
|
||||
|
||||
@@ -100,7 +100,7 @@ Verification:
|
||||
|
||||
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
@@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
|
||||
model: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
|
||||
// Some command templates are lazy promises from MCP prompt resolution.
|
||||
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
|
||||
template: Schema.Unknown,
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
hints: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "Command" })
|
||||
|
||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||
|
||||
export function hints(template: string) {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
// literal pointer to an outside schema — so the walker cannot re-derive it
|
||||
// from AST metadata. Preserve the exact original Zod via ZodOverride.
|
||||
export const ConfigModelID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
|
||||
})
|
||||
export const ConfigModelID = Schema.String
|
||||
|
||||
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as LSPClient from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import * as LSPServer from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
root: Schema.String,
|
||||
status: Schema.Literals(["connected", "error"]).annotate({
|
||||
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
|
||||
}),
|
||||
status: Schema.Literals(["connected", "error"]),
|
||||
}).annotate({ identifier: "LSPStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session } from "@/session/session"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parameters: EffectZod.toJsonSchema(item.parameters),
|
||||
parameters: ToolJsonSchema.fromTool(item),
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = z.infer<typeof APIError.Schema>
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
|
||||
164
packages/opencode/src/tool/json-schema.ts
Normal file
164
packages/opencode/src/tool/json-schema.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import { JsonSchema, Schema } from "effect"
|
||||
import type * as Tool from "./tool"
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
const cache = new WeakMap<Schema.Top, JSONSchema7>()
|
||||
|
||||
export function fromSchema(schema: Schema.Top): JSONSchema7 {
|
||||
const cached = cache.get(schema)
|
||||
if (cached) return cached
|
||||
|
||||
const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true })
|
||||
const result = normalize({
|
||||
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
|
||||
...document.schema,
|
||||
...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}),
|
||||
})
|
||||
const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result))
|
||||
if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value")
|
||||
cache.set(schema, inlined)
|
||||
return inlined
|
||||
}
|
||||
|
||||
export function fromTool(tool: Tool.Def): JSONSchema7 {
|
||||
return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top)
|
||||
}
|
||||
|
||||
function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalize(item))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const required = Array.isArray(value.required)
|
||||
? new Set(value.required.filter((item) => typeof item === "string"))
|
||||
: undefined
|
||||
const schema = Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
key === "properties" && isRecord(item)
|
||||
? Object.fromEntries(
|
||||
Object.entries(item).map(([name, property]) => [
|
||||
name,
|
||||
normalize(property, { stripNull: !required?.has(name) }),
|
||||
]),
|
||||
)
|
||||
: normalize(item),
|
||||
]),
|
||||
)
|
||||
|
||||
if (schema.additionalProperties === true) delete schema.additionalProperties
|
||||
|
||||
if (options.stripNull && Array.isArray(schema.anyOf)) {
|
||||
const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
|
||||
if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull })
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const withoutNull = schema.anyOf
|
||||
const number = withoutNull.find((item) => isRecord(item) && item.type === "number")
|
||||
const nonFinite = withoutNull.filter(
|
||||
(item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)),
|
||||
)
|
||||
if (number && nonFinite.length === withoutNull.length - 1) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ ...number, ...rest })
|
||||
}
|
||||
|
||||
if (isEmptyStructUnion(withoutNull)) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ type: "object", properties: {}, ...rest })
|
||||
}
|
||||
|
||||
if (withoutNull.length === 1 && isRecord(withoutNull[0])) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ ...withoutNull[0], ...rest })
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) {
|
||||
const { allOf, ...rest } = schema
|
||||
return normalize({ ...Object.assign({}, ...allOf), ...rest })
|
||||
}
|
||||
|
||||
if (schema.type === "integer" && schema.maximum === undefined) {
|
||||
return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER }
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isJsonSchema(value: unknown): value is JSONSchema7 {
|
||||
return typeof value === "boolean" || isRecord(value)
|
||||
}
|
||||
|
||||
function isNonFiniteNumber(value: unknown) {
|
||||
return value === "NaN" || value === "Infinity" || value === "-Infinity"
|
||||
}
|
||||
|
||||
function isEmptyStructUnion(items: unknown[]) {
|
||||
return (
|
||||
items.length === 2 &&
|
||||
items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) &&
|
||||
items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) {
|
||||
const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf"))
|
||||
return allOf.every((item) =>
|
||||
Object.keys(item).every((key) => {
|
||||
if (keys.has(key)) return false
|
||||
keys.add(key)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set<string>()): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
|
||||
if (typeof value.$ref === "string" && localDefinitions) {
|
||||
const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1]
|
||||
if (name && !seen.has(name)) {
|
||||
const target = localDefinitions[name]
|
||||
if (target) {
|
||||
const { $ref: _, ...rest } = value
|
||||
return inlineLocalReferences(
|
||||
{ ...(isRecord(target) ? target : {}), ...rest },
|
||||
localDefinitions,
|
||||
new Set(seen).add(name),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]),
|
||||
)
|
||||
}
|
||||
|
||||
function dropDefinitionsIfResolved(value: unknown): unknown {
|
||||
if (!isRecord(value) || hasLocalReference(value)) return value
|
||||
const { $defs: _, definitions: __, ...rest } = value
|
||||
return rest
|
||||
}
|
||||
|
||||
function hasLocalReference(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(hasLocalReference)
|
||||
if (!isRecord(value)) return false
|
||||
if (
|
||||
typeof value.$ref === "string" &&
|
||||
(value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/"))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return Object.values(value).some(hasLocalReference)
|
||||
}
|
||||
|
||||
export * as ToolJsonSchema from "./json-schema"
|
||||
@@ -15,9 +15,9 @@ import { SkillTool } from "./skill"
|
||||
import * as Tool from "./tool"
|
||||
import { Config } from "@/config/config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
|
||||
const custom: Tool.Def[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||
// Plugin tools define their args as a raw Zod shape. Wrap the
|
||||
// derived Zod object in a `Schema.declare` so it slots into the
|
||||
// Schema-typed framework, and annotate with `ZodOverride` so the
|
||||
// walker emits the original Zod object for LLM JSON Schema.
|
||||
const zodParams = z.object(def.args)
|
||||
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
|
||||
[ZodOverride]: zodParams,
|
||||
})
|
||||
// Plugin tools still expose Zod args publicly; keep that compatibility
|
||||
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
||||
const entries = Object.entries(def.args)
|
||||
const allZod = entries.every((entry) => isZodType(entry[1]))
|
||||
const zodParams = allZod ? z.object(def.args) : undefined
|
||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||
const parameters = zodParams
|
||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||
: Schema.Unknown
|
||||
return {
|
||||
id,
|
||||
parameters,
|
||||
jsonSchema,
|
||||
description: def.description,
|
||||
execute: (args, toolCtx) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
|
||||
const output = {
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
jsonSchema: tool.jsonSchema,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
const jsonSchema =
|
||||
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
|
||||
? output.jsonSchema
|
||||
: undefined
|
||||
return {
|
||||
id: tool.id,
|
||||
description: [
|
||||
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
jsonSchema,
|
||||
execute: tool.execute,
|
||||
formatValidationError: tool.formatValidationError,
|
||||
}
|
||||
@@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodType {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition {
|
||||
return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
|
||||
const properties = Object.fromEntries(
|
||||
entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])),
|
||||
)
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required: Object.keys(properties),
|
||||
}
|
||||
}
|
||||
|
||||
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
|
||||
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
|
||||
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
|
||||
const { $defs, ...rest } = result
|
||||
return (
|
||||
$defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest
|
||||
) as JSONSchema7
|
||||
}
|
||||
|
||||
function normalizeZodJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter((entry) =>
|
||||
(entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
|
||||
? false
|
||||
: true,
|
||||
)
|
||||
.map(([key, item]) => [key, normalizeZodJsonSchema(item)]),
|
||||
)
|
||||
}
|
||||
|
||||
function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export * as ToolRegistry from "./registry"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
@@ -38,6 +39,7 @@ export interface Def<
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
jsonSchema?: JSONSchema7
|
||||
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: unknown): string
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
|
||||
.annotate({
|
||||
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
}),
|
||||
default: "markdown",
|
||||
})
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
/**
|
||||
* Create a Schema-backed NamedError-shaped class.
|
||||
@@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod"
|
||||
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
|
||||
*
|
||||
* Preserves the existing surface:
|
||||
* - static `Schema` (Zod schema of the wire shape)
|
||||
* - static `Schema` (Effect schema of the wire shape)
|
||||
* - static `isInstance(x)`
|
||||
* - instance `toObject()` returning `{ name, data }`
|
||||
* - `new X({ ...data }, { cause })`
|
||||
*/
|
||||
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
|
||||
// Wire shape matches the original NamedError output so the SDK stays stable.
|
||||
const dataSchema = Schema.Struct(fields)
|
||||
const wire = z
|
||||
.object({
|
||||
name: z.literal(tag),
|
||||
data: zod(dataSchema),
|
||||
})
|
||||
.meta({ ref: tag })
|
||||
|
||||
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
|
||||
// Wire shape matches the original NamedError output so the SDK stays stable.
|
||||
const effectSchema = Schema.Struct({
|
||||
name: Schema.Literal(tag),
|
||||
data: dataSchema,
|
||||
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
|
||||
type Data = Schema.Schema.Type<typeof dataSchema>
|
||||
|
||||
class NamedSchemaError extends Error {
|
||||
static readonly Schema = wire
|
||||
static readonly Schema = effectSchema
|
||||
static readonly EffectSchema = effectSchema
|
||||
static readonly tag = tag
|
||||
public static isInstance(input: unknown): input is NamedSchemaError {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Layer, Schedule } from "effect"
|
||||
import { Effect, Layer, Schedule, Schema } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
@@ -17,7 +17,7 @@ const retryProvider = "test"
|
||||
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function apiError(headers?: Record<string, string>): MessageV2.APIError {
|
||||
return MessageV2.APIError.Schema.parse(
|
||||
return Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "boom",
|
||||
isRetryable: true,
|
||||
@@ -94,7 +94,7 @@ describe("session.retry.delay", () => {
|
||||
const step = yield* Schedule.toStepWithMetadata(
|
||||
SessionRetry.policy({
|
||||
provider: "test",
|
||||
parse: (err) => MessageV2.APIError.Schema.parse(err),
|
||||
parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema),
|
||||
set: (info) =>
|
||||
status.set(sessionID, {
|
||||
type: "retry",
|
||||
@@ -173,7 +173,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries 500 errors even when isRetryable is false", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Internal server error",
|
||||
isRetryable: false,
|
||||
@@ -186,7 +186,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries 502 bad gateway errors", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Bad gateway",
|
||||
isRetryable: false,
|
||||
@@ -198,7 +198,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries 503 service unavailable errors", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Service unavailable",
|
||||
isRetryable: false,
|
||||
@@ -210,7 +210,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("does not retry 4xx errors when isRetryable is false", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Bad request",
|
||||
isRetryable: false,
|
||||
@@ -222,7 +222,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries ZlibError decompression failures", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
@@ -236,7 +236,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("maps free limits to Go upsell action", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Free usage exceeded",
|
||||
isRetryable: true,
|
||||
@@ -262,7 +262,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("maps Go subscription limits to workspace PAYG upsell", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Subscription quota exceeded. You can continue using free models.",
|
||||
isRetryable: true,
|
||||
@@ -300,7 +300,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("maps Go subscription limits without limit metadata", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Subscription quota exceeded. You can continue using free models.",
|
||||
isRetryable: true,
|
||||
@@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => {
|
||||
)
|
||||
|
||||
test("ECONNRESET socket error is retryable", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
|
||||
@@ -45,6 +45,7 @@ Output: Creates directory 'foo'"
|
||||
"description": "Optional timeout in milliseconds",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer",
|
||||
},
|
||||
"workdir": {
|
||||
@@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionOption",
|
||||
"required": [
|
||||
"label",
|
||||
"description",
|
||||
@@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionPrompt",
|
||||
"required": [
|
||||
"question",
|
||||
"header",
|
||||
@@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"format": {
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
"anyOf": [
|
||||
{
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
{
|
||||
"type": "null",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in seconds (max 120)",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Result, Schema } from "effect"
|
||||
import { toJsonSchema } from "@opencode-ai/core/effect-zod"
|
||||
import { ToolJsonSchema } from "../../src/tool/json-schema"
|
||||
|
||||
// Each tool exports its parameters schema at module scope so this test can
|
||||
// import them without running the tool's Effect-based init. The JSON Schema
|
||||
// snapshot captures what the LLM sees; the parse assertions pin down the
|
||||
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
|
||||
// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
|
||||
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
|
||||
// byte-identical regardless of whether a tool has migrated from zod to Schema.
|
||||
// provider-compatible while tools use Effect Schema internally.
|
||||
|
||||
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
|
||||
import { Parameters as Edit } from "../../src/tool/edit"
|
||||
@@ -32,6 +32,8 @@ const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S[
|
||||
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
|
||||
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
|
||||
|
||||
const toJsonSchema = ToolJsonSchema.fromSchema
|
||||
|
||||
describe("tool parameters", () => {
|
||||
describe("JSON Schema (wire shape)", () => {
|
||||
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
|
||||
@@ -50,6 +52,36 @@ describe("tool parameters", () => {
|
||||
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
|
||||
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
|
||||
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
|
||||
|
||||
test("inlines named child schemas for provider compatibility", () => {
|
||||
const schema = toJsonSchema(Question)
|
||||
expect(schema).not.toHaveProperty("$defs")
|
||||
expect(schema).toMatchObject({
|
||||
properties: {
|
||||
questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves required nullable fields", () => {
|
||||
expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
|
||||
properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps repeated allOf constraints instead of dropping duplicates", () => {
|
||||
expect(
|
||||
toJsonSchema(
|
||||
Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
|
||||
),
|
||||
).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
|
||||
})
|
||||
|
||||
test("bounds bare integer fields to safe integer range", () => {
|
||||
expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
|
||||
properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("apply_patch", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Result, Schema } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||
@@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer, node))
|
||||
const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
|
||||
@@ -141,6 +144,89 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "sql.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'query database',",
|
||||
" args: { query: tool.schema.string().describe('SQL query to execute') },",
|
||||
" execute: async ({ query }) => query,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
|
||||
if (!loaded) throw new Error("custom sql tool was not loaded")
|
||||
expect(loaded?.jsonSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "SQL query to execute" },
|
||||
},
|
||||
required: ["query"],
|
||||
})
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const promptTools = yield* registry.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
const promptTool = promptTools.find((tool) => tool.id === "sql")
|
||||
if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
|
||||
expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
|
||||
properties: {
|
||||
query: { type: "string", description: "SQL query to execute" },
|
||||
},
|
||||
required: ["query"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tools = path.join(test.directory, ".opencode", "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "legacy.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'legacy schema tool',",
|
||||
" args: { text: { type: 'string', description: 'Text to render' } },",
|
||||
" execute: async ({ text }) => text,",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
|
||||
if (!loaded) throw new Error("legacy custom tool was not loaded")
|
||||
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string", description: "Text to render" },
|
||||
},
|
||||
required: ["text"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads tools with external dependencies without crashing", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
@@ -1,754 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
function json(schema: z.ZodTypeAny) {
|
||||
const { $schema: _, ...rest } = z.toJSONSchema(schema)
|
||||
return rest
|
||||
}
|
||||
|
||||
describe("util.effect-zod", () => {
|
||||
test("converts class schemas for route dto shapes", () => {
|
||||
class Method extends Schema.Class<Method>("ProviderAuthMethod")({
|
||||
type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
|
||||
label: Schema.String,
|
||||
}) {}
|
||||
|
||||
const out = zod(Method)
|
||||
|
||||
expect(out.meta()?.ref).toBe("ProviderAuthMethod")
|
||||
expect(
|
||||
out.parse({
|
||||
type: "oauth",
|
||||
label: "OAuth",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "oauth",
|
||||
label: "OAuth",
|
||||
})
|
||||
})
|
||||
|
||||
test("converts structs with optional fields, arrays, and records", () => {
|
||||
const out = zod(
|
||||
Schema.Struct({
|
||||
foo: Schema.optional(Schema.String),
|
||||
bar: Schema.Array(Schema.Number),
|
||||
baz: Schema.Record(Schema.String, Schema.Boolean),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
out.parse({
|
||||
bar: [1, 2],
|
||||
baz: { ok: true },
|
||||
}),
|
||||
).toEqual({
|
||||
bar: [1, 2],
|
||||
baz: { ok: true },
|
||||
})
|
||||
expect(
|
||||
out.parse({
|
||||
foo: "hi",
|
||||
bar: [1],
|
||||
baz: { ok: false },
|
||||
}),
|
||||
).toEqual({
|
||||
foo: "hi",
|
||||
bar: [1],
|
||||
baz: { ok: false },
|
||||
})
|
||||
})
|
||||
|
||||
describe("Tuples", () => {
|
||||
test("fixed-length tuple parses matching array", () => {
|
||||
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
|
||||
expect(out.parse(["a", 1])).toEqual(["a", 1])
|
||||
expect(out.safeParse(["a"]).success).toBe(false)
|
||||
expect(out.safeParse(["a", "b"]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("single-element tuple parses a one-element array", () => {
|
||||
const out = zod(Schema.Tuple([Schema.Boolean]))
|
||||
expect(out.parse([true])).toEqual([true])
|
||||
expect(out.safeParse([true, false]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("tuple inside a union picks the right branch", () => {
|
||||
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
|
||||
expect(out.parse("hello")).toBe("hello")
|
||||
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
|
||||
expect(out.safeParse(["foo"]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("plain arrays still work (no element positions)", () => {
|
||||
const out = zod(Schema.Array(Schema.String))
|
||||
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
|
||||
expect(out.parse([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("string literal unions produce z.enum with enum in JSON Schema", () => {
|
||||
const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||
const out = zod(Action)
|
||||
|
||||
expect(out.parse("allow")).toBe("allow")
|
||||
expect(out.parse("deny")).toBe("deny")
|
||||
expect(() => out.parse("nope")).toThrow()
|
||||
|
||||
// Matches native z.enum JSON Schema output
|
||||
const bridged = json(out)
|
||||
const native = json(z.enum(["allow", "deny", "ask"]))
|
||||
expect(bridged).toEqual(native)
|
||||
expect(bridged.enum).toEqual(["allow", "deny", "ask"])
|
||||
})
|
||||
|
||||
test("ZodOverride annotation provides the Zod schema for branded IDs", () => {
|
||||
const override = z.string().startsWith("per")
|
||||
const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID"))
|
||||
|
||||
const Parent = Schema.Struct({ id: ID, name: Schema.String })
|
||||
const out = zod(Parent)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" })
|
||||
|
||||
const schema = json(out) as any
|
||||
expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||
})
|
||||
|
||||
test("Schema.Class nested in a parent preserves ref via identifier", () => {
|
||||
class Inner extends Schema.Class<Inner>("MyInner")({
|
||||
value: Schema.String,
|
||||
}) {}
|
||||
|
||||
class Outer extends Schema.Class<Outer>("MyOuter")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
const out = zod(Outer)
|
||||
expect(out.meta()?.ref).toBe("MyOuter")
|
||||
|
||||
const shape = (out as any).shape ?? (out as any)._def?.shape?.()
|
||||
expect(shape.inner.meta()?.ref).toBe("MyInner")
|
||||
})
|
||||
|
||||
test("Schema.Class preserves identifier and uses enum format", () => {
|
||||
class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||
permission: Schema.String,
|
||||
pattern: Schema.String,
|
||||
action: Schema.Literals(["allow", "deny", "ask"]),
|
||||
}) {}
|
||||
|
||||
const out = zod(Rule)
|
||||
expect(out.meta()?.ref).toBe("PermissionRule")
|
||||
|
||||
const schema = json(out) as any
|
||||
expect(schema.properties.action).toEqual({
|
||||
type: "string",
|
||||
enum: ["allow", "deny", "ask"],
|
||||
})
|
||||
})
|
||||
|
||||
test("ZodOverride on ID carries pattern through Schema.Class", () => {
|
||||
const ID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().startsWith("per"),
|
||||
})
|
||||
|
||||
class Request extends Schema.Class<Request>("TestRequest")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
const schema = json(zod(Request)) as any
|
||||
expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||
expect(schema.properties.name).toEqual({ type: "string" })
|
||||
})
|
||||
|
||||
test("Permission schemas match original Zod equivalents", () => {
|
||||
const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") })
|
||||
const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })
|
||||
const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") })
|
||||
|
||||
class Tool extends Schema.Class<Tool>("PermissionTool")({
|
||||
messageID: MsgID,
|
||||
callID: Schema.String,
|
||||
}) {}
|
||||
|
||||
class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||
id: PerID,
|
||||
sessionID: SesID,
|
||||
permission: Schema.String,
|
||||
patterns: Schema.Array(Schema.String),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
always: Schema.Array(Schema.String),
|
||||
tool: Schema.optional(Tool),
|
||||
}) {}
|
||||
|
||||
const bridged = json(zod(Request)) as any
|
||||
expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||
expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||
expect(bridged.properties.permission).toEqual({ type: "string" })
|
||||
expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort())
|
||||
|
||||
// Tool field is present with the ref from Schema.Class identifier
|
||||
const toolSchema = json(zod(Tool)) as any
|
||||
expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" })
|
||||
expect(toolSchema.properties.callID).toEqual({ type: "string" })
|
||||
})
|
||||
|
||||
test("ZodOverride survives Schema.brand", () => {
|
||||
const override = z.string().startsWith("ses")
|
||||
const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID"))
|
||||
|
||||
// The branded schema's AST still has the override
|
||||
class Parent extends Schema.Class<Parent>("Parent")({
|
||||
sessionID: ID,
|
||||
}) {}
|
||||
|
||||
const schema = json(zod(Parent)) as any
|
||||
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||
})
|
||||
|
||||
describe("Schema.check translation", () => {
|
||||
test("filter returning string triggers refinement with that message", () => {
|
||||
const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
|
||||
const schema = zod(Schema.Number.check(isEven))
|
||||
|
||||
expect(schema.parse(4)).toBe(4)
|
||||
const result = schema.safeParse(3)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("expected an even number")
|
||||
})
|
||||
|
||||
test("filter returning false triggers refinement with fallback message", () => {
|
||||
const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
|
||||
const schema = zod(Schema.String.check(nonEmpty))
|
||||
|
||||
expect(schema.parse("hi")).toBe("hi")
|
||||
const result = schema.safeParse("")
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toMatch(/./)
|
||||
})
|
||||
|
||||
test("filter returning undefined passes validation", () => {
|
||||
const alwaysOk = Schema.makeFilter(() => undefined)
|
||||
const schema = zod(Schema.Number.check(alwaysOk))
|
||||
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("annotations.message on the filter is used when filter returns false", () => {
|
||||
const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
|
||||
const schema = zod(Schema.Number.check(positive))
|
||||
|
||||
const result = schema.safeParse(-1)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("must be positive")
|
||||
})
|
||||
|
||||
test("cross-field check on a record flags missing key", () => {
|
||||
const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
|
||||
"required" in data ? undefined : "missing 'required' key",
|
||||
)
|
||||
const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
|
||||
|
||||
expect(schema.parse({ required: { enabled: true } })).toEqual({
|
||||
required: { enabled: true },
|
||||
})
|
||||
|
||||
const result = schema.safeParse({ other: { enabled: true } })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("missing 'required' key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("StructWithRest / catchall", () => {
|
||||
test("struct with a string-keyed record rest parses known AND extra keys", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: Schema.optional(Schema.String),
|
||||
baseURL: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
)
|
||||
|
||||
// Known fields come through as declared
|
||||
expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
|
||||
|
||||
// Extra keys are preserved (catchall)
|
||||
expect(
|
||||
schema.parse({
|
||||
apiKey: "sk-x",
|
||||
baseURL: "https://api.example.com",
|
||||
customField: "anything",
|
||||
nested: { foo: 1 },
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "sk-x",
|
||||
baseURL: "https://api.example.com",
|
||||
customField: "anything",
|
||||
nested: { foo: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
test("catchall value type constrains the extras", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
count: Schema.Number,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Number)],
|
||||
),
|
||||
)
|
||||
|
||||
// Known field + numeric extras
|
||||
expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
|
||||
|
||||
// Non-numeric extra is rejected
|
||||
expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
|
||||
})
|
||||
|
||||
test("JSON schema output marks additionalProperties appropriately", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
)
|
||||
const shape = json(schema) as { additionalProperties?: unknown }
|
||||
// Presence of `additionalProperties` (truthy or a schema) signals catchall.
|
||||
expect(shape.additionalProperties).not.toBe(false)
|
||||
expect(shape.additionalProperties).toBeDefined()
|
||||
})
|
||||
|
||||
test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
|
||||
const schema = zod(Schema.Struct({ id: Schema.String }))
|
||||
expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("transforms (Schema.decodeTo)", () => {
|
||||
test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
|
||||
// Models the account/account.ts DurationFromSeconds pattern.
|
||||
const SecondsToMs = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n * 1000),
|
||||
encode: SchemaGetter.transform((ms: number) => ms / 1000),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(SecondsToMs)
|
||||
expect(schema.parse(3)).toBe(3000)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
})
|
||||
|
||||
test("String -> Number via parseInt decode", () => {
|
||||
const ParsedInt = Schema.String.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
|
||||
encode: SchemaGetter.transform((n: number) => String(n)),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(ParsedInt)
|
||||
expect(schema.parse("42")).toBe(42)
|
||||
expect(schema.parse("0")).toBe(0)
|
||||
})
|
||||
|
||||
test("transform inside a struct field applies per-field", () => {
|
||||
const Field = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n + 1),
|
||||
encode: SchemaGetter.transform((n: number) => n - 1),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
plain: Schema.Number,
|
||||
bumped: Field,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
|
||||
})
|
||||
|
||||
test("chained decodeTo composes transforms in order", () => {
|
||||
// String -> Number (parseInt) -> Number (doubled).
|
||||
// Exercises the encoded() reduce, not just a single link.
|
||||
const Chained = Schema.String.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
|
||||
encode: SchemaGetter.transform((n: number) => String(n)),
|
||||
}),
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n * 2),
|
||||
encode: SchemaGetter.transform((n: number) => n / 2),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(Chained)
|
||||
expect(schema.parse("21")).toBe(42)
|
||||
expect(schema.parse("0")).toBe(0)
|
||||
})
|
||||
|
||||
test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
|
||||
// Schema.Class uses Declaration + encoding under the hood to construct
|
||||
// class instances. The walker must NOT apply that transform, or zod
|
||||
// parsing would return class instances instead of plain objects.
|
||||
class Method extends Schema.Class<Method>("TxTestMethod")({
|
||||
type: Schema.String,
|
||||
value: Schema.Number,
|
||||
}) {}
|
||||
|
||||
const schema = zod(Method)
|
||||
const parsed = schema.parse({ type: "oauth", value: 1 })
|
||||
expect(parsed).toEqual({ type: "oauth", value: 1 })
|
||||
// Guardrail: ensure we didn't get back a Method instance.
|
||||
expect(parsed).not.toBeInstanceOf(Method)
|
||||
})
|
||||
})
|
||||
|
||||
describe("optimizations", () => {
|
||||
test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
|
||||
const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
|
||||
const left = zod(shared)
|
||||
const right = zod(shared)
|
||||
expect(left).toBe(right)
|
||||
})
|
||||
|
||||
test("nested reuse of the same AST reuses the cached Zod child", () => {
|
||||
// Two different parents embed the same inner schema. The inner zod
|
||||
// child should be identical by reference inside both parents.
|
||||
class Inner extends Schema.Class<Inner>("MemoTestInner")({
|
||||
value: Schema.String,
|
||||
}) {}
|
||||
|
||||
class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
|
||||
const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
|
||||
expect(shapeA.inner).toBe(shapeB.inner)
|
||||
})
|
||||
|
||||
test("multiple checks run in a single refinement layer (all fire on one value)", () => {
|
||||
// Three checks attached to the same schema. All three must run and
|
||||
// report — asserting that no check silently got dropped when we
|
||||
// flattened into one superRefine.
|
||||
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
|
||||
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
|
||||
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
|
||||
|
||||
const schema = zod(Schema.Number.check(positive).check(even).check(under100))
|
||||
|
||||
const neg = schema.safeParse(-3)
|
||||
expect(neg.success).toBe(false)
|
||||
expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
|
||||
|
||||
const big = schema.safeParse(101)
|
||||
expect(big.success).toBe(false)
|
||||
expect(big.error!.issues.map((i) => i.message)).toContain("too big")
|
||||
|
||||
// Passing value satisfies all three
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
|
||||
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
|
||||
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
|
||||
const group = Schema.makeFilterGroup([positive, even])
|
||||
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
|
||||
|
||||
const schema = zod(Schema.Number.check(group).check(under100))
|
||||
|
||||
const bad = schema.safeParse(-3)
|
||||
expect(bad.success).toBe(false)
|
||||
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
|
||||
})
|
||||
})
|
||||
|
||||
describe("well-known refinement translation", () => {
|
||||
test("Schema.isInt emits type: integer in JSON Schema", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()))
|
||||
const native = json(z.number().int())
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse(3)).toBe(3)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
|
||||
expect((json(schema) as any).exclusiveMinimum).toBe(0)
|
||||
expect(schema.parse(1)).toBe(1)
|
||||
expect(schema.safeParse(0).success).toBe(false)
|
||||
expect(schema.safeParse(-1).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
|
||||
expect((json(schema) as any).minimum).toBe(0)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
expect(schema.safeParse(-1).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
|
||||
expect((json(schema) as any).exclusiveMaximum).toBe(10)
|
||||
expect(schema.parse(9)).toBe(9)
|
||||
expect(schema.safeParse(10).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
|
||||
expect((json(schema) as any).maximum).toBe(10)
|
||||
expect(schema.parse(10)).toBe(10)
|
||||
expect(schema.safeParse(11).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
|
||||
expect((json(schema) as any).multipleOf).toBe(5)
|
||||
expect(schema.parse(10)).toBe(10)
|
||||
expect(schema.safeParse(7).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isFinite validates at runtime", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isFinite()))
|
||||
expect(schema.parse(1)).toBe(1)
|
||||
expect(schema.safeParse(Infinity).success).toBe(false)
|
||||
expect(schema.safeParse(NaN).success).toBe(false)
|
||||
})
|
||||
|
||||
test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
|
||||
const native = json(z.number().int().positive())
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse(3)).toBe(3)
|
||||
expect(schema.safeParse(0).success).toBe(false)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
})
|
||||
|
||||
test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
|
||||
const native = json(z.number().int().min(0))
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
expect(schema.safeParse(-1).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isBetween emits both bounds", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.minimum).toBe(1)
|
||||
expect(shape.maximum).toBe(10)
|
||||
expect(schema.parse(5)).toBe(5)
|
||||
expect(schema.safeParse(11).success).toBe(false)
|
||||
expect(schema.safeParse(0).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
|
||||
const schema = zod(
|
||||
Schema.Number.check(
|
||||
Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
|
||||
),
|
||||
)
|
||||
const shape = json(schema) as any
|
||||
expect(shape.exclusiveMinimum).toBe(1)
|
||||
expect(shape.exclusiveMaximum).toBe(10)
|
||||
expect(schema.parse(5)).toBe(5)
|
||||
expect(schema.safeParse(1).success).toBe(false)
|
||||
expect(schema.safeParse(10).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt32()))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.type).toBe("integer")
|
||||
expect(shape.minimum).toBe(-2147483648)
|
||||
expect(shape.maximum).toBe(2147483647)
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
expect(schema.safeParse(2147483648).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMinLength on string emits minLength", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isMinLength(3)))
|
||||
expect((json(schema) as any).minLength).toBe(3)
|
||||
expect(schema.parse("abc")).toBe("abc")
|
||||
expect(schema.safeParse("ab").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMaxLength on string emits maxLength", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
|
||||
expect((json(schema) as any).maxLength).toBe(5)
|
||||
expect(schema.parse("abcde")).toBe("abcde")
|
||||
expect(schema.safeParse("abcdef").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isLengthBetween on string emits both bounds", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.minLength).toBe(2)
|
||||
expect(shape.maxLength).toBe(4)
|
||||
expect(schema.parse("abc")).toBe("abc")
|
||||
expect(schema.safeParse("a").success).toBe(false)
|
||||
expect(schema.safeParse("abcde").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMinLength on array emits minItems", () => {
|
||||
const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
|
||||
expect((json(schema) as any).minItems).toBe(1)
|
||||
expect(schema.parse(["x"])).toEqual(["x"])
|
||||
expect(schema.safeParse([]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isPattern emits pattern", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
|
||||
expect((json(schema) as any).pattern).toBe("^per")
|
||||
expect(schema.parse("per_abc")).toBe("per_abc")
|
||||
expect(schema.safeParse("abc").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
|
||||
const native = json(z.string().startsWith("per"))
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse("per_abc")).toBe("per_abc")
|
||||
expect(schema.safeParse("abc").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
|
||||
const native = json(z.string().endsWith(".json"))
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse("a.json")).toBe("a.json")
|
||||
expect(schema.safeParse("a.txt").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isUUID emits format: uuid", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isUUID()))
|
||||
expect((json(schema) as any).format).toBe("uuid")
|
||||
})
|
||||
|
||||
test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
|
||||
// isInt is well-known (translates to .int()); the anonymous filter falls
|
||||
// back to superRefine.
|
||||
const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
|
||||
|
||||
const shape = json(schema) as any
|
||||
// Well-known translation is preserved — type is integer, not plain number
|
||||
expect(shape.type).toBe("integer")
|
||||
|
||||
// Runtime: both constraints fire
|
||||
expect(schema.parse(3)).toBe(3)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
const seven = schema.safeParse(7)
|
||||
expect(seven.success).toBe(false)
|
||||
expect(seven.error!.issues[0].message).toBe("no sevens allowed")
|
||||
})
|
||||
|
||||
test("inside a struct field, well-known refinements propagate through", () => {
|
||||
// Mirrors config.ts port: z.number().int().positive().optional()
|
||||
const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
|
||||
const schema = zod(Schema.Struct({ port: Port }))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.properties.port.type).toBe("integer")
|
||||
expect(shape.properties.port.exclusiveMinimum).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Schema.optionalWith defaults", () => {
|
||||
test("parsing undefined returns the default value", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
|
||||
expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
|
||||
})
|
||||
|
||||
test("parsing a real value returns that value (default does not fire)", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
|
||||
})
|
||||
|
||||
test("default on a number field", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({})).toEqual({ count: 42 })
|
||||
expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
|
||||
})
|
||||
|
||||
test("multiple defaulted fields inside a struct", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
|
||||
inner: Schema.String,
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({ inner: "hi" })).toEqual({
|
||||
leader: "ctrl-x",
|
||||
quit: "ctrl-c",
|
||||
inner: "hi",
|
||||
})
|
||||
expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
|
||||
leader: "a",
|
||||
quit: "b",
|
||||
inner: "c",
|
||||
})
|
||||
})
|
||||
|
||||
test("JSON Schema output includes the default key", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
const shape = json(schema) as any
|
||||
expect(shape.properties.mode.default).toBe("ctrl-x")
|
||||
})
|
||||
|
||||
test("default referencing a computed value resolves when evaluated", () => {
|
||||
// Simulates `keybinds.ts` style of per-platform defaults: the default is
|
||||
// produced by an Effect that computes a value at decode time.
|
||||
const platform = "darwin"
|
||||
const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
|
||||
const shape = json(schema) as any
|
||||
expect(shape.properties.command_palette.default).toBe("cmd-k")
|
||||
})
|
||||
|
||||
test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
|
||||
const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
|
||||
expect(schema.parse({})).toEqual({})
|
||||
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user