Selaa lähdekoodia

feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186)

Kit Langton 4 kuukautta sitten
vanhempi
sitoutus
1eafb2160a

+ 14 - 2
packages/opencode/src/util/effect-zod.ts

@@ -107,15 +107,27 @@ function union(ast: SchemaAST.Union): 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))
   }
 
-  if (ast.indexSignatures.length > 0) return fail(ast)
+  // 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)])))
+  }
 
-  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 {

+ 69 - 0
packages/opencode/test/util/effect-zod.test.ts

@@ -263,4 +263,73 @@ describe("util.effect-zod", () => {
       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" })
+    })
+  })
 })