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

feat(effect-zod): translate well-known filters into native Zod methods (#23209)

Kit Langton 20 часов назад
Родитель
Сommit
bb90f3bbf9
2 измененных файлов с 275 добавлено и 5 удалено
  1. 84 5
      packages/opencode/src/util/effect-zod.ts
  2. 191 0
      packages/opencode/test/util/effect-zod.test.ts

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

@@ -75,9 +75,12 @@ function decode(transformation: SchemaAST.Link["transformation"], value: unknown
   return Option.getOrElse(exit.value, () => value)
   return Option.getOrElse(exit.value, () => value)
 }
 }
 
 
-// Flatten FilterGroups and any nested variants into a linear list of Filters
-// so we can run all of them inside a single Zod .superRefine wrapper instead
-// of stacking N wrapper layers (one per check).
+// Flatten FilterGroups and any nested variants into a linear list of Filters.
+// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
+// translated into native Zod methods so their JSON Schema output includes
+// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
+// Anything else falls back to a single .superRefine layer — runtime-only,
+// emits no JSON Schema constraint.
 function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
 function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
   const filters: SchemaAST.Filter<unknown>[] = []
   const filters: SchemaAST.Filter<unknown>[] = []
   const collect = (c: SchemaAST.Check<unknown>) => {
   const collect = (c: SchemaAST.Check<unknown>) => {
@@ -85,8 +88,19 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST
     else filters.push(c)
     else filters.push(c)
   }
   }
   checks.forEach(collect)
   checks.forEach(collect)
-  return out.superRefine((value, ctx) => {
-    for (const filter of filters) {
+
+  const unhandled: SchemaAST.Filter<unknown>[] = []
+  const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
+    const next = translateFilter(acc, filter)
+    if (next) return next
+    unhandled.push(filter)
+    return acc
+  }, out)
+
+  if (unhandled.length === 0) return translated
+
+  return translated.superRefine((value, ctx) => {
+    for (const filter of unhandled) {
       const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
       const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
       if (!issue) continue
       if (!issue) continue
       const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
       const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
@@ -95,6 +109,71 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST
   })
   })
 }
 }
 
 
+// Translate a well-known Effect Schema filter into a native Zod method call on
+// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
+// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
+// construction time. Returns `undefined` for unrecognised filters so the
+// caller can fall back to the generic .superRefine path.
+function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
+  const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
+  if (!meta || typeof meta._tag !== "string") return undefined
+  switch (meta._tag) {
+    case "isInt":
+      return call(out, "int")
+    case "isFinite":
+      return call(out, "finite")
+    case "isGreaterThan":
+      return call(out, "gt", meta.exclusiveMinimum)
+    case "isGreaterThanOrEqualTo":
+      return call(out, "gte", meta.minimum)
+    case "isLessThan":
+      return call(out, "lt", meta.exclusiveMaximum)
+    case "isLessThanOrEqualTo":
+      return call(out, "lte", meta.maximum)
+    case "isBetween": {
+      const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
+      if (!lo) return undefined
+      return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
+    }
+    case "isMultipleOf":
+      return call(out, "multipleOf", meta.divisor)
+    case "isMinLength":
+      return call(out, "min", meta.minLength)
+    case "isMaxLength":
+      return call(out, "max", meta.maxLength)
+    case "isLengthBetween": {
+      const lo = call(out, "min", meta.minimum)
+      if (!lo) return undefined
+      return call(lo, "max", meta.maximum)
+    }
+    case "isPattern":
+      return call(out, "regex", meta.regExp)
+    case "isStartsWith":
+      return call(out, "startsWith", meta.startsWith)
+    case "isEndsWith":
+      return call(out, "endsWith", meta.endsWith)
+    case "isIncludes":
+      return call(out, "includes", meta.includes)
+    case "isUUID":
+      return call(out, "uuid")
+    case "isULID":
+      return call(out, "ulid")
+    case "isBase64":
+      return call(out, "base64")
+    case "isBase64Url":
+      return call(out, "base64url")
+  }
+  return undefined
+}
+
+// Invoke a named Zod method on `target` if it exists, otherwise return
+// undefined so the caller can fall back. Using this helper instead of a
+// typed cast keeps `translateFilter` free of per-case narrowing noise.
+function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
+  const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
+  return typeof fn === "function" ? fn.apply(target, args) : undefined
+}
+
 function issueMessage(issue: any): string | undefined {
 function issueMessage(issue: any): string | undefined {
   if (typeof issue?.annotations?.message === "string") return issue.annotations.message
   if (typeof issue?.annotations?.message === "string") return issue.annotations.message
   if (typeof issue?.message === "string") return issue.message
   if (typeof issue?.message === "string") return issue.message

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

@@ -478,4 +478,195 @@ describe("util.effect-zod", () => {
       expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
       expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
     })
     })
   })
   })
+
+  describe("well-known refinement translation", () => {
+    test("Schema.isInt emits type: integer in JSON Schema", () => {
+      const schema = zod(Schema.Number.check(Schema.isInt()))
+      const native = json(z.number().int())
+      expect(json(schema)).toEqual(native)
+      expect(schema.parse(3)).toBe(3)
+      expect(schema.safeParse(1.5).success).toBe(false)
+    })
+
+    test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
+      const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
+      expect((json(schema) as any).exclusiveMinimum).toBe(0)
+      expect(schema.parse(1)).toBe(1)
+      expect(schema.safeParse(0).success).toBe(false)
+      expect(schema.safeParse(-1).success).toBe(false)
+    })
+
+    test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
+      const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
+      expect((json(schema) as any).minimum).toBe(0)
+      expect(schema.parse(0)).toBe(0)
+      expect(schema.safeParse(-1).success).toBe(false)
+    })
+
+    test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
+      const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
+      expect((json(schema) as any).exclusiveMaximum).toBe(10)
+      expect(schema.parse(9)).toBe(9)
+      expect(schema.safeParse(10).success).toBe(false)
+    })
+
+    test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
+      const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
+      expect((json(schema) as any).maximum).toBe(10)
+      expect(schema.parse(10)).toBe(10)
+      expect(schema.safeParse(11).success).toBe(false)
+    })
+
+    test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
+      const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
+      expect((json(schema) as any).multipleOf).toBe(5)
+      expect(schema.parse(10)).toBe(10)
+      expect(schema.safeParse(7).success).toBe(false)
+    })
+
+    test("Schema.isFinite validates at runtime", () => {
+      const schema = zod(Schema.Number.check(Schema.isFinite()))
+      expect(schema.parse(1)).toBe(1)
+      expect(schema.safeParse(Infinity).success).toBe(false)
+      expect(schema.safeParse(NaN).success).toBe(false)
+    })
+
+    test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
+      const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
+      const native = json(z.number().int().positive())
+      expect(json(schema)).toEqual(native)
+      expect(schema.parse(3)).toBe(3)
+      expect(schema.safeParse(0).success).toBe(false)
+      expect(schema.safeParse(1.5).success).toBe(false)
+    })
+
+    test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
+      const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
+      const native = json(z.number().int().min(0))
+      expect(json(schema)).toEqual(native)
+      expect(schema.parse(0)).toBe(0)
+      expect(schema.safeParse(-1).success).toBe(false)
+    })
+
+    test("Schema.isBetween emits both bounds", () => {
+      const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
+      const shape = json(schema) as any
+      expect(shape.minimum).toBe(1)
+      expect(shape.maximum).toBe(10)
+      expect(schema.parse(5)).toBe(5)
+      expect(schema.safeParse(11).success).toBe(false)
+      expect(schema.safeParse(0).success).toBe(false)
+    })
+
+    test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
+      const schema = zod(
+        Schema.Number.check(
+          Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
+        ),
+      )
+      const shape = json(schema) as any
+      expect(shape.exclusiveMinimum).toBe(1)
+      expect(shape.exclusiveMaximum).toBe(10)
+      expect(schema.parse(5)).toBe(5)
+      expect(schema.safeParse(1).success).toBe(false)
+      expect(schema.safeParse(10).success).toBe(false)
+    })
+
+    test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
+      const schema = zod(Schema.Number.check(Schema.isInt32()))
+      const shape = json(schema) as any
+      expect(shape.type).toBe("integer")
+      expect(shape.minimum).toBe(-2147483648)
+      expect(shape.maximum).toBe(2147483647)
+      expect(schema.parse(42)).toBe(42)
+      expect(schema.safeParse(1.5).success).toBe(false)
+      expect(schema.safeParse(2147483648).success).toBe(false)
+    })
+
+    test("Schema.isMinLength on string emits minLength", () => {
+      const schema = zod(Schema.String.check(Schema.isMinLength(3)))
+      expect((json(schema) as any).minLength).toBe(3)
+      expect(schema.parse("abc")).toBe("abc")
+      expect(schema.safeParse("ab").success).toBe(false)
+    })
+
+    test("Schema.isMaxLength on string emits maxLength", () => {
+      const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
+      expect((json(schema) as any).maxLength).toBe(5)
+      expect(schema.parse("abcde")).toBe("abcde")
+      expect(schema.safeParse("abcdef").success).toBe(false)
+    })
+
+    test("Schema.isLengthBetween on string emits both bounds", () => {
+      const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
+      const shape = json(schema) as any
+      expect(shape.minLength).toBe(2)
+      expect(shape.maxLength).toBe(4)
+      expect(schema.parse("abc")).toBe("abc")
+      expect(schema.safeParse("a").success).toBe(false)
+      expect(schema.safeParse("abcde").success).toBe(false)
+    })
+
+    test("Schema.isMinLength on array emits minItems", () => {
+      const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
+      expect((json(schema) as any).minItems).toBe(1)
+      expect(schema.parse(["x"])).toEqual(["x"])
+      expect(schema.safeParse([]).success).toBe(false)
+    })
+
+    test("Schema.isPattern emits pattern", () => {
+      const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
+      expect((json(schema) as any).pattern).toBe("^per")
+      expect(schema.parse("per_abc")).toBe("per_abc")
+      expect(schema.safeParse("abc").success).toBe(false)
+    })
+
+    test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
+      const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
+      const native = json(z.string().startsWith("per"))
+      expect(json(schema)).toEqual(native)
+      expect(schema.parse("per_abc")).toBe("per_abc")
+      expect(schema.safeParse("abc").success).toBe(false)
+    })
+
+    test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
+      const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
+      const native = json(z.string().endsWith(".json"))
+      expect(json(schema)).toEqual(native)
+      expect(schema.parse("a.json")).toBe("a.json")
+      expect(schema.safeParse("a.txt").success).toBe(false)
+    })
+
+    test("Schema.isUUID emits format: uuid", () => {
+      const schema = zod(Schema.String.check(Schema.isUUID()))
+      expect((json(schema) as any).format).toBe("uuid")
+    })
+
+    test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
+      // isInt is well-known (translates to .int()); the anonymous filter falls
+      // back to superRefine.
+      const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
+      const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
+
+      const shape = json(schema) as any
+      // Well-known translation is preserved — type is integer, not plain number
+      expect(shape.type).toBe("integer")
+
+      // Runtime: both constraints fire
+      expect(schema.parse(3)).toBe(3)
+      expect(schema.safeParse(1.5).success).toBe(false)
+      const seven = schema.safeParse(7)
+      expect(seven.success).toBe(false)
+      expect(seven.error!.issues[0].message).toBe("no sevens allowed")
+    })
+
+    test("inside a struct field, well-known refinements propagate through", () => {
+      // Mirrors config.ts port: z.number().int().positive().optional()
+      const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
+      const schema = zod(Schema.Struct({ port: Port }))
+      const shape = json(schema) as any
+      expect(shape.properties.port.type).toBe("integer")
+      expect(shape.properties.port.exclusiveMinimum).toBe(0)
+    })
+  })
 })
 })