Просмотр исходного кода

feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207)

Kit Langton 1 день назад
Родитель
Сommit
36119ff173

+ 43 - 5
packages/opencode/src/util/effect-zod.ts

@@ -40,7 +40,12 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
   // 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.
-  const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
+  //
+  // 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"
+  const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
   const base = hasTransform ? encoded(ast) : body(ast)
   const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
   const desc = SchemaAST.resolveDescription(ast)
@@ -217,10 +222,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
 function opt(ast: SchemaAST.AST): z.ZodTypeAny {
   if (ast._tag !== "Union") return fail(ast)
   const items = ast.types.filter((item) => item._tag !== "Undefined")
-  if (items.length === 1) return walk(items[0]).optional()
-  if (items.length > 1)
-    return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
-  return z.undefined().optional()
+  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 {

+ 86 - 1
packages/opencode/test/util/effect-zod.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { Schema, SchemaGetter } from "effect"
+import { Effect, Schema, SchemaGetter } from "effect"
 import z from "zod"
 
 import { zod, ZodOverride } from "../../src/util/effect-zod"
@@ -669,4 +669,89 @@ describe("util.effect-zod", () => {
       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" })
+    })
+  })
 })