فهرست منبع

Migrate runtime validators to Effect Schema (#26975)

Kit Langton 3 ماه پیش
والد
کامیت
1007630347

+ 25 - 22
packages/opencode/src/cli/cmd/tui/context/editor-zed.ts

@@ -1,33 +1,36 @@
 import { Database } from "bun:sqlite"
 import os from "node:os"
 import path from "node:path"
-import z from "zod"
+import { Option, Schema } from "effect"
 import { Filesystem } from "@/util/filesystem"
 import type { EditorSelection } from "./editor"
 
-const ZedEditorRowSchema = z.object({
-  item_kind: z.string(),
-  editor_id: z.number().nullable(),
-  workspace_id: z.number(),
-  workspace_paths: z.string().nullable(),
-  timestamp: z.string(),
-  buffer_path: z.string().nullable(),
+const ZedEditorRowSchema = Schema.Struct({
+  item_kind: Schema.String,
+  editor_id: Schema.NullOr(Schema.Number),
+  workspace_id: Schema.Number,
+  workspace_paths: Schema.NullOr(Schema.String),
+  timestamp: Schema.String,
+  buffer_path: Schema.NullOr(Schema.String),
 })
 
-const ZedSelectionRowSchema = z.object({
-  selection_start: z.number().nullable(),
-  selection_end: z.number().nullable(),
+const ZedSelectionRowSchema = Schema.Struct({
+  selection_start: Schema.NullOr(Schema.Number),
+  selection_end: Schema.NullOr(Schema.Number),
 })
 
-const ZedEditorContentsSchema = z.object({
-  contents: z.string().nullable(),
+const ZedEditorContentsSchema = Schema.Struct({
+  contents: Schema.NullOr(Schema.String),
 })
 
+const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
+const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
+const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
+
 const utf8 = new TextEncoder()
 
-type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
+type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
 type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
-type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
 
 export type ZedSelectionResult =
   | { type: "selection"; selection: EditorSelection }
@@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
       .all()
 
     const rows = raw.flatMap((row) => {
-      const parsed = ZedEditorRowSchema.safeParse(row)
-      return parsed.success ? [parsed.data] : []
+      const parsed = decodeZedEditorRow(row)
+      return Option.isSome(parsed) ? [parsed.value] : []
     })
 
     if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
@@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
       .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
 
     const selections = raw.flatMap((selection) => {
-      const parsed = ZedSelectionRowSchema.safeParse(selection)
-      return parsed.success ? [parsed.data] : []
+      const parsed = decodeZedSelectionRow(selection)
+      return Option.isSome(parsed) ? [parsed.value] : []
     })
 
     if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
@@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
   let db: Database | undefined
   try {
     db = new Database(dbPath, { readonly: true })
-    const parsed = ZedEditorContentsSchema.safeParse(
+    const parsed = decodeZedEditorContents(
       db
         .query(
           `select contents
@@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
         )
         .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
     )
-    if (!parsed.success) return { type: "unavailable" as const }
-    return { type: "contents" as const, contents: parsed.data.contents }
+    if (Option.isNone(parsed)) return { type: "unavailable" as const }
+    return { type: "contents" as const, contents: parsed.value.contents }
   } catch {
     return { type: "unavailable" as const }
   } finally {

+ 85 - 76
packages/opencode/src/cli/cmd/tui/context/editor.ts

@@ -3,92 +3,102 @@ import os from "node:os"
 import path from "node:path"
 import { onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
-import z from "zod"
+import { Option, Schema, SchemaGetter } from "effect"
 import { isRecord } from "@/util/record"
 import { createSimpleContext } from "./helper"
 import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
 
 const MCP_PROTOCOL_VERSION = "2025-11-25"
 
-const JsonRpcMessageSchema = z.object({
-  id: z.union([z.number(), z.string(), z.null()]).optional(),
-  method: z.string().optional(),
-  params: z.unknown().optional(),
-  result: z.unknown().optional(),
-  error: z
-    .object({
-      code: z.number().optional(),
-      message: z.string().optional(),
-    })
-    .optional(),
+const JsonRpcMessageSchema = Schema.Struct({
+  id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])),
+  method: Schema.optional(Schema.String),
+  params: Schema.optional(Schema.Unknown),
+  result: Schema.optional(Schema.Unknown),
+  error: Schema.optional(
+    Schema.Struct({
+      code: Schema.optional(Schema.Number),
+      message: Schema.optional(Schema.String),
+    }),
+  ),
 })
 
-const PositionSchema = z.object({
-  line: z.number(),
-  character: z.number(),
+const PositionSchema = Schema.Struct({
+  line: Schema.Number,
+  character: Schema.Number,
 })
 
-const EditorSelectionRangeSchema = z.object({
-  text: z.string(),
-  selection: z.object({
+const EditorSelectionRangeSchema = Schema.Struct({
+  text: Schema.String,
+  selection: Schema.Struct({
     start: PositionSchema,
     end: PositionSchema,
   }),
 })
 
-const EditorSelectionSchema = z
-  .union([
-    z.object({
-      filePath: z.string(),
-      source: z.enum(["websocket", "zed"]).optional(),
-      ranges: z.array(EditorSelectionRangeSchema).min(1),
-    }),
-    z.object({
-      text: z.string(),
-      filePath: z.string(),
-      source: z.enum(["websocket", "zed"]).optional(),
-      selection: z.object({
-        start: PositionSchema,
-        end: PositionSchema,
-      }),
+const EditorSelectionRangesSchema = Schema.Struct({
+  filePath: Schema.String,
+  source: Schema.optional(Schema.Literals(["websocket", "zed"])),
+  ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),
+})
+
+const EditorSelectionSchema = Schema.Union([
+  EditorSelectionRangesSchema,
+  Schema.Struct({
+    text: Schema.String,
+    filePath: Schema.String,
+    source: Schema.optional(Schema.Literals(["websocket", "zed"])),
+    selection: Schema.Struct({
+      start: PositionSchema,
+      end: PositionSchema,
     }),
-  ])
-  .transform((value) =>
-    "ranges" in value
-      ? value
-      : {
-          filePath: value.filePath,
-          source: value.source,
-          ranges: [
-            {
-              text: value.text,
-              selection: value.selection,
-            },
-          ],
-        },
-  )
-
-const EditorMentionSchema = z.object({
-  filePath: z.string(),
-  lineStart: z.number(),
-  lineEnd: z.number(),
+  }),
+]).pipe(
+  Schema.decodeTo(EditorSelectionRangesSchema, {
+    decode: SchemaGetter.transform((value) =>
+      "ranges" in value
+        ? value
+        : {
+            filePath: value.filePath,
+            source: value.source,
+            ranges: [
+              {
+                text: value.text,
+                selection: value.selection,
+              },
+            ],
+          },
+    ),
+    encode: SchemaGetter.passthrough({ strict: false }),
+  }),
+)
+
+const EditorMentionSchema = Schema.Struct({
+  filePath: Schema.String,
+  lineStart: Schema.Number,
+  lineEnd: Schema.Number,
 })
 
-const EditorServerInfoSchema = z.object({
-  protocolVersion: z.string().optional(),
-  serverInfo: z
-    .object({
-      name: z.string().optional(),
-      version: z.string().optional(),
-    })
-    .optional(),
+const EditorServerInfoSchema = Schema.Struct({
+  protocolVersion: Schema.optional(Schema.String),
+  serverInfo: Schema.optional(
+    Schema.Struct({
+      name: Schema.optional(Schema.String),
+      version: Schema.optional(Schema.String),
+    }),
+  ),
 })
 
-type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
-export type EditorSelection = z.infer<typeof EditorSelectionSchema>
-export type EditorMention = z.infer<typeof EditorMentionSchema>
+const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)
+const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)
+const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)
+const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
+
+type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>
+export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>
+export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>
 export type EditorLabelState = "pending" | "sent" | "none"
-type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
+type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
 
 type EditorConnection = {
   url: string
@@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
         const message = parseMessage(event.data)
         if (!message) return
 
-        const selection =
-          message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
-        if (selection?.success) {
-          setSelection({ ...selection.data, source: "websocket" })
+        const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none()
+        if (Option.isSome(selection)) {
+          setSelection({ ...selection.value, source: "websocket" })
           return
         }
 
-        const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
-        if (mention?.success) {
-          mentionListeners.forEach((listener) => listener(mention.data))
+        const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none()
+        if (Option.isSome(mention)) {
+          mentionListeners.forEach((listener) => listener(mention.value))
           return
         }
 
@@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
         pending.delete(message.id)
         if (message.error) return
 
-        const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
-        if (initialize?.success) {
-          setStore("server", initialize.data)
+        const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none()
+        if (Option.isSome(initialize)) {
+          setStore("server", initialize.value)
           send({ method: "notifications/initialized" })
           return
         }
@@ -447,7 +456,7 @@ function parseMessage(value: unknown) {
   if (typeof value !== "string") return
 
   try {
-    return JsonRpcMessageSchema.parse(JSON.parse(value))
+    return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value)))
   } catch {
     return
   }

+ 27 - 25
packages/opencode/src/mcp/auth.ts

@@ -1,33 +1,35 @@
 import path from "path"
-import z from "zod"
 import { Global } from "@opencode-ai/core/global"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Option, Schema } from "effect"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 
-export const Tokens = z.object({
-  accessToken: z.string(),
-  refreshToken: z.string().optional(),
-  expiresAt: z.number().optional(),
-  scope: z.string().optional(),
+export const Tokens = Schema.Struct({
+  accessToken: Schema.mutableKey(Schema.String),
+  refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
+  expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
+  scope: Schema.mutableKey(Schema.optional(Schema.String)),
 })
-export type Tokens = z.infer<typeof Tokens>
+export type Tokens = Schema.Schema.Type<typeof Tokens>
 
-export const ClientInfo = z.object({
-  clientId: z.string(),
-  clientSecret: z.string().optional(),
-  clientIdIssuedAt: z.number().optional(),
-  clientSecretExpiresAt: z.number().optional(),
+export const ClientInfo = Schema.Struct({
+  clientId: Schema.mutableKey(Schema.String),
+  clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
+  clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
+  clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
 })
-export type ClientInfo = z.infer<typeof ClientInfo>
-
-export const Entry = z.object({
-  tokens: Tokens.optional(),
-  clientInfo: ClientInfo.optional(),
-  codeVerifier: z.string().optional(),
-  oauthState: z.string().optional(),
-  serverUrl: z.string().optional(),
+export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
+
+export const Entry = Schema.Struct({
+  tokens: Schema.mutableKey(Schema.optional(Tokens)),
+  clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)),
+  codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)),
+  oauthState: Schema.mutableKey(Schema.optional(Schema.String)),
+  serverUrl: Schema.mutableKey(Schema.optional(Schema.String)),
 })
-export type Entry = z.infer<typeof Entry>
+export type Entry = Schema.Schema.Type<typeof Entry>
+
+const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
+type AuthData = Record<string, Entry>
 
 const filepath = path.join(Global.Path.data, "mcp-auth.json")
 
@@ -56,8 +58,8 @@ export const layer = Layer.effect(
 
     const all = Effect.fn("McpAuth.all")(function* () {
       return yield* fs.readJson(filepath).pipe(
-        Effect.map((data) => data as Record<string, Entry>),
-        Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
+        Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData),
+        Effect.catch(() => Effect.succeed({} as AuthData)),
       )
     })
 
@@ -93,7 +95,7 @@ export const layer = Layer.effect(
         yield* set(mcpName, entry, serverUrl)
       })
 
-    const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
+    const clearField = (field: keyof Entry, spanName: string) =>
       Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
         const entry = yield* get(mcpName)
         if (entry) {

+ 39 - 38
packages/opencode/src/plugin/github-copilot/models.ts

@@ -1,50 +1,51 @@
-import { z } from "zod"
 import type { Model } from "@opencode-ai/sdk/v2"
+import { Schema } from "effect"
 
-export const schema = z.object({
-  data: z.array(
-    z.object({
-      model_picker_enabled: z.boolean(),
-      id: z.string(),
-      name: z.string(),
+export const schema = Schema.Struct({
+  data: Schema.Array(
+    Schema.Struct({
+      model_picker_enabled: Schema.Boolean,
+      id: Schema.String,
+      name: Schema.String,
       // every version looks like: `{model.id}-YYYY-MM-DD`
-      version: z.string(),
-      supported_endpoints: z.array(z.string()).optional(),
-      policy: z
-        .object({
-          state: z.string().optional(),
-        })
-        .optional(),
-      capabilities: z.object({
-        family: z.string(),
-        limits: z.object({
-          max_context_window_tokens: z.number(),
-          max_output_tokens: z.number(),
-          max_prompt_tokens: z.number(),
-          vision: z
-            .object({
-              max_prompt_image_size: z.number(),
-              max_prompt_images: z.number(),
-              supported_media_types: z.array(z.string()),
-            })
-            .optional(),
+      version: Schema.String,
+      supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
+      policy: Schema.optional(
+        Schema.Struct({
+          state: Schema.optional(Schema.String),
         }),
-        supports: z.object({
-          adaptive_thinking: z.boolean().optional(),
-          max_thinking_budget: z.number().optional(),
-          min_thinking_budget: z.number().optional(),
-          reasoning_effort: z.array(z.string()).optional(),
-          streaming: z.boolean(),
-          structured_outputs: z.boolean().optional(),
-          tool_calls: z.boolean(),
-          vision: z.boolean().optional(),
+      ),
+      capabilities: Schema.Struct({
+        family: Schema.String,
+        limits: Schema.Struct({
+          max_context_window_tokens: Schema.Number,
+          max_output_tokens: Schema.Number,
+          max_prompt_tokens: Schema.Number,
+          vision: Schema.optional(
+            Schema.Struct({
+              max_prompt_image_size: Schema.Number,
+              max_prompt_images: Schema.Number,
+              supported_media_types: Schema.Array(Schema.String),
+            }),
+          ),
+        }),
+        supports: Schema.Struct({
+          adaptive_thinking: Schema.optional(Schema.Boolean),
+          max_thinking_budget: Schema.optional(Schema.Number),
+          min_thinking_budget: Schema.optional(Schema.Number),
+          reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
+          streaming: Schema.Boolean,
+          structured_outputs: Schema.optional(Schema.Boolean),
+          tool_calls: Schema.Boolean,
+          vision: Schema.optional(Schema.Boolean),
         }),
       }),
     }),
   ),
 })
 
-type Item = z.infer<typeof schema>["data"][number]
+type Item = Schema.Schema.Type<typeof schema>["data"][number]
+const decodeModels = Schema.decodeUnknownSync(schema)
 
 function build(key: string, remote: Item, url: string, prev?: Model): Model {
   const reasoning =
@@ -165,7 +166,7 @@ export async function get(
     if (!res.ok) {
       throw new Error(`Failed to fetch models: ${res.status}`)
     }
-    return schema.parse(await res.json())
+    return decodeModels(await res.json())
   })
 
   const result = { ...existing }