summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-17 17:06:55 -0400
committerGitHub <[email protected]>2026-04-17 21:06:55 +0000
commit5980b0a5eeb8f7a8dc31433f86e458dbe3358269 (patch)
treea0c6c67c9155906c3ff0138d114ced99e601f4f1
parent89029a20ef1548f6637c15f63f39f281e4a6dae7 (diff)
downloadopencode-5980b0a5eeb8f7a8dc31433f86e458dbe3358269.tar.gz
opencode-5980b0a5eeb8f7a8dc31433f86e458dbe3358269.zip
feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178)
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui-schema.ts2
-rw-r--r--packages/opencode/src/config/config.ts2
-rw-r--r--packages/opencode/src/config/plugin.ts15
-rw-r--r--packages/opencode/src/util/effect-zod.ts13
-rw-r--r--packages/opencode/test/util/effect-zod.test.ts28
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 66569efea..ed79e8e52 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 f228878d0..0f6d71f44 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 7d335bcc5..ebc3e2230 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<typeof Options>
+export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Options = Schema.Schema.Type<typeof Options>
// 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<typeof Spec>
+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<typeof Spec>
export type Scope = "global" | "local"
diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
index c3240deaa..771795ba6 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<z.ZodTypeAny>])
}
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 1c84c96f3..ba67a60e6 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", () => {