From 5980b0a5eeb8f7a8dc31433f86e458dbe3358269 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 17:06:55 -0400 Subject: [PATCH] feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178) --- .../src/cli/cmd/tui/config/tui-schema.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/plugin.ts | 15 ++++++---- packages/opencode/src/util/effect-zod.ts | 13 +++++++-- .../opencode/test/util/effect-zod.test.ts | 28 +++++++++++++++++-- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 66569efea5..ed79e8e524 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -31,7 +31,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), - plugin: ConfigPlugin.Spec.array().optional(), + plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f228878d04..0f6d71f447 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -113,7 +113,7 @@ export const Info = z "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", ), // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. - plugin: ConfigPlugin.Spec.array().optional(), + plugin: ConfigPlugin.Spec.zod.array().optional(), share: z .enum(["manual", "auto", "disabled"]) .optional() diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 7d335bcc53..ebc3e2230d 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -1,16 +1,21 @@ import { Glob } from "@opencode-ai/shared/util/glob" -import z from "zod" +import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import path from "path" -const Options = z.record(z.string(), z.unknown()) -export type Options = z.infer +export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) -export type Spec = z.infer +export const Spec = Schema.Union([ + Schema.String, + Schema.mutable(Schema.Tuple([Schema.String, Options])), +]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Spec = Schema.Schema.Type export type Scope = "global" | "local" diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index c3240deaac..771795ba68 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -119,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny { } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { - if (ast.elements.length > 0) return fail(ast) - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) + // 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]) } function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 1c84c96f3a..ba67a60e6d 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -61,8 +61,32 @@ describe("util.effect-zod", () => { }) }) - test("throws for unsupported tuple schemas", () => { - expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") + 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", () => {