2
0
Эх сурвалжийг харах

feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178)

Kit Langton 3 өдөр өмнө
parent
commit
5980b0a5ee

+ 1 - 1
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)

+ 1 - 1
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()

+ 10 - 5
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"
 

+ 10 - 3
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 {

+ 26 - 2
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", () => {