diff --git a/Makefile b/Makefile index f16f2a2..96a50f2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ all: gen build fmt lint test gen: src/codersdk.gen.ts -src/codersdk.gen.ts: scripts/typegen/main.go scripts/typegen/zod.go scripts/typegen/go.mod +src/codersdk.gen.ts: scripts/typegen/main.go scripts/typegen/zod/zod.go scripts/typegen/go.mod @cd scripts/typegen && go run . > ../../src/codersdk.gen.ts.tmp @mv src/codersdk.gen.ts.tmp src/codersdk.gen.ts @bun run format -- src/codersdk.gen.ts || true diff --git a/dist/index.js b/dist/index.js index 8c5716a..7240d0a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -36491,6 +36491,7 @@ function normalizeBaseUrl(coderURL) { } // src/codersdk.gen.ts +var ChatBusyBehaviorSchema = exports_external.enum(["interrupt", "queue"]); var ChatClientTypeSchema = exports_external.enum(["api", "ui"]); var ChatDiffStatusSchema = exports_external.object({ chat_id: exports_external.string(), @@ -36539,6 +36540,20 @@ var ChatFileMetadataSchema = exports_external.object({ mime_type: exports_external.string(), created_at: exports_external.string() }); +var ChatInputPartTypeSchema = exports_external.enum([ + "file", + "file-reference", + "text" +]); +var ChatInputPartSchema = exports_external.object({ + type: ChatInputPartTypeSchema, + text: exports_external.string().optional(), + file_id: exports_external.string().optional(), + file_name: exports_external.string().optional(), + start_line: exports_external.number().optional(), + end_line: exports_external.number().optional(), + content: exports_external.string().optional() +}); var ChatPlanModeSchema = exports_external.enum(["plan"]); var ChatStatusSchema = exports_external.enum([ "completed", @@ -36580,21 +36595,6 @@ var ChatSchema = exports_external.object({ client_type: ChatClientTypeSchema, children: exports_external.array(exports_external.lazy(() => ChatSchema)) }); -var ChatBusyBehaviorSchema = exports_external.enum(["interrupt", "queue"]); -var ChatInputPartTypeSchema = exports_external.enum([ - "file", - "file-reference", - "text" -]); -var ChatInputPartSchema = exports_external.object({ - type: ChatInputPartTypeSchema, - text: exports_external.string().optional(), - file_id: exports_external.string().optional(), - file_name: exports_external.string().optional(), - start_line: exports_external.number().optional(), - end_line: exports_external.number().optional(), - content: exports_external.string().optional() -}); var CreateChatMessageRequestSchema = exports_external.object({ content: exports_external.array(ChatInputPartSchema), model_config_id: exports_external.string().optional(), @@ -36645,6 +36645,11 @@ var OrganizationSchema = MinimalOrganizationSchema.extend({ updated_at: exports_external.string(), is_default: exports_external.boolean() }); +var SlimRoleSchema = exports_external.object({ + name: exports_external.string(), + display_name: exports_external.string(), + organization_id: exports_external.string().optional() +}); var UserStatusSchema = exports_external.enum(["active", "dormant", "suspended"]); var ReducedUserSchema = MinimalUserSchema.extend({ email: exports_external.string(), @@ -36656,11 +36661,6 @@ var ReducedUserSchema = MinimalUserSchema.extend({ is_service_account: exports_external.boolean().optional(), theme_preference: exports_external.string().optional() }); -var SlimRoleSchema = exports_external.object({ - name: exports_external.string(), - display_name: exports_external.string(), - organization_id: exports_external.string().optional() -}); var UserSchema = ReducedUserSchema.extend({ organization_ids: exports_external.array(exports_external.string()), roles: exports_external.array(SlimRoleSchema), diff --git a/scripts/typegen/go.mod b/scripts/typegen/go.mod index daf245e..a407c58 100644 --- a/scripts/typegen/go.mod +++ b/scripts/typegen/go.mod @@ -11,11 +11,12 @@ require ( github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/coder/v2 v2.34.0-rc.0.0.20260528065010-cfa343e45666 // indirect - github.com/coder/guts v1.7.0 // indirect + github.com/coder/guts v1.7.1 // indirect github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect github.com/coder/serpent v0.15.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-oidc/v3 v3.18.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect github.com/fatih/structtag v1.2.0 // indirect @@ -36,9 +37,11 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/udp v0.1.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/zclconf/go-cty v1.18.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/scripts/typegen/go.sum b/scripts/typegen/go.sum index 2414d71..d69e7af 100644 --- a/scripts/typegen/go.sum +++ b/scripts/typegen/go.sum @@ -16,6 +16,8 @@ github.com/coder/coder/v2 v2.34.0-rc.0.0.20260528065010-cfa343e45666 h1:imJX12Fi github.com/coder/coder/v2 v2.34.0-rc.0.0.20260528065010-cfa343e45666/go.mod h1:5UG9p30Gqu3UoUz0fo18JzD5DBQj2S9ArDtbI8yhIr8= github.com/coder/guts v1.7.0 h1:TaZ/PR9wgN8dlbcckaWV1MxkkuEFZRwSRwBBEm8dYXs= github.com/coder/guts v1.7.0/go.mod h1:30SShdvpmsauNlsNjECRB5AppScjYk08rf2ZVpH3MFg= +github.com/coder/guts v1.7.1 h1:NrxGxV1TeyOW7+1vBMZjJI1pYHTvlqWEh66GOZ3lsHI= +github.com/coder/guts v1.7.1/go.mod h1:30SShdvpmsauNlsNjECRB5AppScjYk08rf2ZVpH3MFg= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/serpent v0.15.0 h1:jobR7DnPsxzEMD0cRiailwlY+4v6HAPS/8emIgBpaIU= @@ -26,6 +28,8 @@ github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d4 github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd h1:QMSNEh9uQkDjyPwu/J541GgSH+4hw+0skJDIj9HJ3mE= @@ -72,6 +76,8 @@ github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8= github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -85,6 +91,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= diff --git a/scripts/typegen/main.go b/scripts/typegen/main.go index d916b27..c5583f9 100644 --- a/scripts/typegen/main.go +++ b/scripts/typegen/main.go @@ -9,14 +9,15 @@ package main import ( "fmt" "log" - "maps" "os" - "slices" "strings" "github.com/coder/guts" "github.com/coder/guts/bindings" + "github.com/coder/guts/bindings/walk" "github.com/coder/guts/config" + + "github.com/coder/agents-chat-action/scripts/typegen/zod" ) // Types the action needs from the Coder API. Only root types that @@ -63,38 +64,47 @@ func main() { log.Fatalf("to typescript: %v", err) } + // Pre-Zod mutations that reshape enums and optional fields. ts.ApplyMutations( config.EnumAsTypes, config.SimplifyOmitEmpty, ) - // Collect all nodes then filter to wanted types plus any - // types they reference that are also in the full set. + // Compute wanted types before Zod rewrites the AST + // (collectRefs works on Interface/Alias). allNodes := make(map[string]bindings.Node) ts.ForEach(func(name string, node bindings.Node) { allNodes[name] = node }) - - // Resolve transitive references so we don't emit broken - // schema references. included := resolveTransitive(allNodes, wantedTypes) + wantedNames := make(map[string]bool, len(included)) + for name := range included { + wantedNames[name] = true + } - // Topological sort: emit dependencies before dependents. - names := topoSort(included) - - var b strings.Builder - b.WriteString("// Code generated by 'make gen'. DO NOT EDIT.\n") - b.WriteString("import { z } from \"zod\";\n\n") + // Rewrite Interface/Alias into Zod schemas, then export. + ts.ApplyMutations( + zod.AsSchemas, + config.ExportTypes, + ) - for _, name := range names { - s := serializeNode(name, included[name]) - if s != "" { - b.WriteString(s) - b.WriteString("\n") + // Serialize only the wanted types, sorted by dependencies. + output, err := ts.SerializeInOrder(func(nodes map[string]bindings.Node) []bindings.Node { + // Filter to wanted types and their schema bindings. + filtered := make(map[string]bindings.Node, len(wantedNames)*2) + for name, node := range nodes { + baseName := strings.TrimSuffix(name, "Schema") + if wantedNames[baseName] { + filtered[name] = node + } } + return zod.SortByDependencies(filtered) + }) + if err != nil { + log.Fatalf("serialize: %v", err) } - _, _ = fmt.Fprint(os.Stdout, b.String()) + _, _ = fmt.Fprint(os.Stdout, output) } // resolveTransitive starts from the seeds and pulls in any @@ -121,95 +131,21 @@ func resolveTransitive(allNodes map[string]bindings.Node, seeds map[string]bool) return result } -// collectRefs extracts all type reference names from a node. +// collectRefs extracts all type reference names from a node +// using the generic AST walker. func collectRefs(node bindings.Node) []string { - var refs []string - var walkExpr func(bindings.ExpressionType) - walkExpr = func(expr bindings.ExpressionType) { - if expr == nil { - return - } - switch e := expr.(type) { - case *bindings.ReferenceType: - refs = append(refs, e.Name.Ref()) - for _, arg := range e.Arguments { - walkExpr(arg) - } - case *bindings.ArrayType: - walkExpr(e.Node) - case *bindings.ArrayLiteralType: - for _, el := range e.Elements { - walkExpr(el) - } - case *bindings.UnionType: - for _, t := range e.Types { - walkExpr(t) - } - case *bindings.TypeLiteralNode: - for _, m := range e.Members { - walkExpr(m.Type) - } - case *bindings.TypeIntersection: - for _, t := range e.Types { - walkExpr(t) - } - case *bindings.ExpressionWithTypeArguments: - walkExpr(e.Expression) - for _, arg := range e.Arguments { - walkExpr(arg) - } - case *bindings.OperatorNodeType: - walkExpr(e.Type) - case *bindings.TupleType: - walkExpr(e.Node) - } - } - switch n := node.(type) { - case *bindings.Interface: - for _, f := range n.Fields { - walkExpr(f.Type) - } - for _, h := range n.Heritage { - for _, arg := range h.Args { - walkExpr(arg) - } - } - case *bindings.Alias: - walkExpr(n.Type) - } - return refs + v := &refVisitor{} + walk.Walk(v, node) + return v.refs } -// topoSort returns names in dependency order (dependencies first). -// Ties within the same depth level are broken alphabetically. -func topoSort(nodes map[string]bindings.Node) []string { - visited := make(map[string]bool) - var order []string - - var visit func(string) - visit = func(name string) { - if visited[name] { - return - } - visited[name] = true - node, ok := nodes[name] - if !ok { - return - } - // Visit dependencies first. - deps := collectRefs(node) - slices.Sort(deps) - for _, dep := range deps { - if _, exists := nodes[dep]; exists { - visit(dep) - } - } - order = append(order, name) - } +type refVisitor struct { + refs []string +} - // Start visits in alphabetical order for stable output. - for _, name := range slices.Sorted(maps.Keys(nodes)) { - visit(name) +func (v *refVisitor) Visit(node bindings.Node) walk.Visitor { + if ref, ok := node.(*bindings.ReferenceType); ok { + v.refs = append(v.refs, ref.Name.Ref()) } - return order + return v } diff --git a/scripts/typegen/main_test.go b/scripts/typegen/main_test.go index bd38bc5..27248cc 100644 --- a/scripts/typegen/main_test.go +++ b/scripts/typegen/main_test.go @@ -1,7 +1,6 @@ package main import ( - "strings" "testing" "github.com/coder/guts/bindings" @@ -65,357 +64,6 @@ func TestResolveTransitiveStopsAtMissingRefs(t *testing.T) { } } -func TestTopoSort(t *testing.T) { - t.Parallel() - - nodes := map[string]bindings.Node{ - "C": &bindings.Interface{ - Name: bindings.Identifier{Name: "C"}, - Fields: []*bindings.PropertySignature{{Name: "x", Type: kw(bindings.KeywordString)}}, - }, - "B": &bindings.Interface{ - Name: bindings.Identifier{Name: "B"}, - Fields: []*bindings.PropertySignature{{Name: "c", Type: ref("C")}}, - }, - "A": &bindings.Interface{ - Name: bindings.Identifier{Name: "A"}, - Fields: []*bindings.PropertySignature{{Name: "b", Type: ref("B")}}, - }, - } - - order := topoSort(nodes) - - indexOf := func(name string) int { - for i, n := range order { - if n == name { - return i - } - } - return -1 - } - - if indexOf("C") > indexOf("B") { - t.Error("C should appear before B (B depends on C)") - } - if indexOf("B") > indexOf("A") { - t.Error("B should appear before A (A depends on B)") - } -} - -func TestTopoSortSelfReference(t *testing.T) { - t.Parallel() - - nodes := map[string]bindings.Node{ - "Tree": &bindings.Interface{ - Name: bindings.Identifier{Name: "Tree"}, - Fields: []*bindings.PropertySignature{ - {Name: "children", Type: bindings.Array(ref("Tree"))}, - }, - }, - } - - order := topoSort(nodes) - - if len(order) != 1 || order[0] != "Tree" { - t.Errorf("expected [Tree], got %v", order) - } -} - -func TestCollectRefsFindsNestedRefs(t *testing.T) { - t.Parallel() - - node := &bindings.Interface{ - Name: bindings.Identifier{Name: "Test"}, - Fields: []*bindings.PropertySignature{ - {Name: "a", Type: ref("A")}, - {Name: "b", Type: bindings.Array(ref("B"))}, - {Name: "c", Type: bindings.Union(ref("C"), &bindings.Null{})}, - }, - } - - refs := collectRefs(node) - refSet := make(map[string]bool) - for _, r := range refs { - refSet[r] = true - } - - for _, want := range []string{"A", "B", "C"} { - if !refSet[want] { - t.Errorf("expected ref %q in results", want) - } - } -} - -func TestCollectRefsArrayLiteralType(t *testing.T) { - t.Parallel() - - node := &bindings.Alias{ - Name: bindings.Identifier{Name: "Test"}, - Type: &bindings.ArrayLiteralType{ - Elements: []bindings.ExpressionType{ - ref("Foo"), - ref("Bar"), - }, - }, - } - - refs := collectRefs(node) - refSet := make(map[string]bool) - for _, r := range refs { - refSet[r] = true - } - - for _, want := range []string{"Foo", "Bar"} { - if !refSet[want] { - t.Errorf("expected ref %q in results", want) - } - } -} - -func TestExprToZodNullableUnion(t *testing.T) { - t.Parallel() - - expr := bindings.Union(kw(bindings.KeywordString), &bindings.Null{}) - result := exprToZod(expr, "") - - if result != "z.string().nullable()" { - t.Errorf("expected z.string().nullable(), got %q", result) - } -} - -func TestExprToZodSingleMemberUnion(t *testing.T) { - t.Parallel() - - expr := bindings.Union(kw(bindings.KeywordString)) - result := exprToZod(expr, "") - - if result != "z.string()" { - t.Errorf("expected z.string(), got %q", result) - } -} - -func TestExprToZodRecord(t *testing.T) { - t.Parallel() - - expr := bindings.Reference( - bindings.Identifier{Name: "Record"}, - kw(bindings.KeywordString), - kw(bindings.KeywordString), - ) - result := exprToZod(expr, "") - - if result != "z.record(z.string(), z.string())" { - t.Errorf("expected z.record(...), got %q", result) - } -} - -func TestExprToZodSelfReference(t *testing.T) { - t.Parallel() - - expr := ref("Tree") - result := exprToZod(expr, "Tree") - - if !strings.Contains(result, "z.lazy") { - t.Errorf("expected z.lazy() for self-reference, got %q", result) - } -} - -func TestExprToZodNonSelfReference(t *testing.T) { - t.Parallel() - - expr := ref("Other") - result := exprToZod(expr, "Tree") - - if result != "OtherSchema" { - t.Errorf("expected OtherSchema, got %q", result) - } -} - -func TestSerializeNodeInterface(t *testing.T) { - t.Parallel() - - node := &bindings.Interface{ - Name: bindings.Identifier{Name: "Foo"}, - Fields: []*bindings.PropertySignature{ - {Name: "name", Type: kw(bindings.KeywordString)}, - }, - } - - result := serializeNode("Foo", node) - - if !strings.Contains(result, "export const FooSchema = z.object(") { - t.Errorf("expected z.object declaration, got:\n%s", result) - } - if !strings.Contains(result, "name: z.string()") { - t.Errorf("expected name field, got:\n%s", result) - } -} - -func TestSerializeNodeAlias(t *testing.T) { - t.Parallel() - - node := &bindings.Alias{ - Name: bindings.Identifier{Name: "MyString"}, - Type: kw(bindings.KeywordString), - } - - result := serializeNode("MyString", node) - - if !strings.Contains(result, "export const MyStringSchema = z.string()") { - t.Errorf("expected z.string() alias, got:\n%s", result) - } - if !strings.Contains(result, "export type MyString = z.infer") { - t.Errorf("expected type export, got:\n%s", result) - } -} - -func TestSerializeNodeUnknownReturnsEmpty(t *testing.T) { - t.Parallel() - - // LiteralKeyword is a Node but not Interface or Alias. - kword := bindings.KeywordString - result := serializeNode("X", &kword) - - if result != "" { - t.Errorf("expected empty string for unknown node type, got %q", result) - } -} - -func TestSerializeInterfaceWithHeritage(t *testing.T) { - t.Parallel() - - iface := &bindings.Interface{ - Name: bindings.Identifier{Name: "Child"}, - Heritage: []*bindings.HeritageClause{ - bindings.HeritageClauseExtends(ref("Parent")), - }, - Fields: []*bindings.PropertySignature{ - {Name: "extra", Type: kw(bindings.KeywordString)}, - }, - } - - result := serializeInterface("Child", iface) - - if !strings.Contains(result, "ParentSchema.extend") { - t.Errorf("expected ParentSchema.extend, got:\n%s", result) - } - if !strings.Contains(result, "extra: z.string()") { - t.Errorf("expected extra field, got:\n%s", result) - } -} - -func TestSerializeStringEnum(t *testing.T) { - t.Parallel() - - union := &bindings.UnionType{ - Types: []bindings.ExpressionType{ - &bindings.LiteralType{Value: "a"}, - &bindings.LiteralType{Value: "b"}, - }, - } - - result := serializeStringEnum("Status", union) - - if !strings.Contains(result, `z.enum([`) { - t.Errorf("expected z.enum, got:\n%s", result) - } - if !strings.Contains(result, `"a"`) || !strings.Contains(result, `"b"`) { - t.Errorf("expected enum values, got:\n%s", result) - } -} - -func TestObjectLiteralToZod(t *testing.T) { - t.Parallel() - - tl := &bindings.TypeLiteralNode{ - Members: []*bindings.PropertySignature{ - {Name: "x", Type: kw(bindings.KeywordNumber)}, - {Name: "y", Type: kw(bindings.KeywordString), QuestionToken: true}, - }, - } - - result := objectLiteralToZod(tl, "") - - if !strings.Contains(result, "z.object(") { - t.Errorf("expected z.object, got:\n%s", result) - } - if !strings.Contains(result, "x: z.number()") { - t.Errorf("expected x field, got:\n%s", result) - } - if !strings.Contains(result, "y: z.string().optional()") { - t.Errorf("expected optional y field, got:\n%s", result) - } -} - -func TestIntersectionToZod(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - t.Parallel() - inter := &bindings.TypeIntersection{} - if got := intersectionToZod(inter, ""); got != "z.unknown()" { - t.Errorf("expected z.unknown(), got %q", got) - } - }) - - t.Run("single", func(t *testing.T) { - t.Parallel() - inter := &bindings.TypeIntersection{ - Types: []bindings.ExpressionType{kw(bindings.KeywordString)}, - } - if got := intersectionToZod(inter, ""); got != "z.string()" { - t.Errorf("expected z.string(), got %q", got) - } - }) - - t.Run("two types", func(t *testing.T) { - t.Parallel() - inter := &bindings.TypeIntersection{ - Types: []bindings.ExpressionType{ - ref("A"), - ref("B"), - }, - } - got := intersectionToZod(inter, "") - if !strings.Contains(got, "z.intersection(ASchema, BSchema)") { - t.Errorf("expected z.intersection(ASchema, BSchema), got %q", got) - } - }) - - t.Run("three types nested", func(t *testing.T) { - t.Parallel() - inter := &bindings.TypeIntersection{ - Types: []bindings.ExpressionType{ - ref("A"), - ref("B"), - ref("C"), - }, - } - got := intersectionToZod(inter, "") - expected := "z.intersection(z.intersection(ASchema, BSchema), CSchema)" - if got != expected { - t.Errorf("expected %q, got %q", expected, got) - } - }) -} - -func TestSerializeNodeSelfReferenceUsesLazy(t *testing.T) { - t.Parallel() - - node := &bindings.Interface{ - Name: bindings.Identifier{Name: "Tree"}, - Fields: []*bindings.PropertySignature{ - {Name: "children", Type: bindings.Array(ref("Tree"))}, - }, - } - - result := serializeNode("Tree", node) - - if !strings.Contains(result, "z.lazy(") { - t.Errorf("expected z.lazy for self-reference, got:\n%s", result) - } -} - func ref(name string) *bindings.ReferenceType { return bindings.Reference(bindings.Identifier{Name: name}) } diff --git a/scripts/typegen/zod.go b/scripts/typegen/zod.go deleted file mode 100644 index 70e0518..0000000 --- a/scripts/typegen/zod.go +++ /dev/null @@ -1,260 +0,0 @@ -// Zod v4 serializer for guts AST nodes. -// -// Local copy of the unreleased github.com/coder/guts/zod package. -// Replace with an import when released. -package main - -import ( - "fmt" - "strings" - - "github.com/coder/guts/bindings" -) - -func serializeNode(name string, node bindings.Node) string { - switch n := node.(type) { - case *bindings.Interface: - return serializeInterface(name, n) - case *bindings.Alias: - return serializeAlias(name, n) - default: - return "" - } -} - -func serializeInterface(name string, iface *bindings.Interface) string { - schema := schemaName(name) - var b strings.Builder - - // Handle struct embedding (heritage clauses) via .extend(). - base := "" - for _, h := range iface.Heritage { - for _, arg := range h.Args { - found := "" - if ewta, ok := arg.(*bindings.ExpressionWithTypeArguments); ok { - if r, ok := ewta.Expression.(*bindings.ReferenceType); ok { - found = schemaName(r.Name.Ref()) - } - } - if rt, ok := arg.(*bindings.ReferenceType); ok { - found = schemaName(rt.Name.Ref()) - } - if found != "" { - if base != "" { - panic(fmt.Sprintf("serializeInterface(%q): multiple heritage bases (%s, %s); Zod does not support multiple inheritance", name, base, found)) - } - base = found - } - } - } - - if base != "" && len(iface.Fields) > 0 { - b.WriteString(fmt.Sprintf("export const %s = %s.extend({\n", schema, base)) - } else if base != "" { - b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, base)) - b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) - return b.String() - } else { - b.WriteString(fmt.Sprintf("export const %s = z.object({\n", schema)) - } - for _, f := range iface.Fields { - zodType := exprToZod(f.Type, name) - if f.QuestionToken { - zodType += ".optional()" - } - b.WriteString(fmt.Sprintf(" %s: %s,\n", f.Name, zodType)) - } - b.WriteString("});\n") - b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) - return b.String() -} - -func serializeAlias(name string, alias *bindings.Alias) string { - if union, ok := alias.Type.(*bindings.UnionType); ok { - if isStringLiteralUnion(union) { - return serializeStringEnum(name, union) - } - } - - schema := schemaName(name) - zodType := exprToZod(alias.Type, name) - var b strings.Builder - b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, zodType)) - b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) - return b.String() -} - -func serializeStringEnum(name string, union *bindings.UnionType) string { - schema := schemaName(name) - var values []string - for _, t := range union.Types { - if lit, ok := t.(*bindings.LiteralType); ok { - values = append(values, fmt.Sprintf(" %q", lit.Value)) - } - } - var b strings.Builder - b.WriteString(fmt.Sprintf("export const %s = z.enum([\n", schema)) - b.WriteString(strings.Join(values, ",\n")) - b.WriteString(",\n]);\n") - b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) - return b.String() -} - -func exprToZod(expr bindings.ExpressionType, selfName string) string { - if expr == nil { - return "z.unknown()" - } - switch e := expr.(type) { - case *bindings.LiteralKeyword: - return keywordToZod(e) - case *bindings.LiteralType: - return literalToZod(e) - case *bindings.ReferenceType: - return referenceToZod(e, selfName) - case *bindings.ArrayType: - return fmt.Sprintf("z.array(%s)", exprToZod(e.Node, selfName)) - case *bindings.UnionType: - return unionToZod(e, selfName) - case *bindings.Null: - return "z.null()" - case *bindings.TypeLiteralNode: - return objectLiteralToZod(e, selfName) - case *bindings.TypeIntersection: - return intersectionToZod(e, selfName) - case *bindings.TupleType: - return fmt.Sprintf("z.array(%s)", exprToZod(e.Node, selfName)) - case *bindings.OperatorNodeType: - return exprToZod(e.Type, selfName) - default: - return "z.unknown()" - } -} - -func keywordToZod(kw *bindings.LiteralKeyword) string { - switch *kw { - case bindings.KeywordString: - return "z.string()" - case bindings.KeywordNumber: - return "z.number()" - case bindings.KeywordBoolean: - return "z.boolean()" - case bindings.KeywordAny, bindings.KeywordUnknown: - return "z.unknown()" - case bindings.KeywordVoid, bindings.KeywordUndefined: - return "z.undefined()" - case bindings.KeywordNever: - return "z.never()" - default: - return "z.unknown()" - } -} - -func literalToZod(lit *bindings.LiteralType) string { - switch v := lit.Value.(type) { - case string: - return fmt.Sprintf("z.literal(%q)", v) - case bool: - return fmt.Sprintf("z.literal(%t)", v) - case int64: - return fmt.Sprintf("z.literal(%d)", v) - case float64: - return fmt.Sprintf("z.literal(%g)", v) - default: - return fmt.Sprintf("z.literal(%v)", v) - } -} - -func referenceToZod(ref *bindings.ReferenceType, selfName string) string { - name := ref.Name.Ref() - if name == "Record" && len(ref.Arguments) == 2 { - return fmt.Sprintf("z.record(%s, %s)", - exprToZod(ref.Arguments[0], selfName), - exprToZod(ref.Arguments[1], selfName), - ) - } - switch name { - case "Omit", "Pick", "Partial", "Required": - return "z.unknown()" - } - // Self-referential types need z.lazy() to avoid - // reference-before-declaration errors. - if selfName != "" && name == selfName { - return fmt.Sprintf("z.lazy((): z.ZodType => %s)", schemaName(name)) - } - return schemaName(name) -} - -func unionToZod(u *bindings.UnionType, selfName string) string { - nonNull := make([]bindings.ExpressionType, 0, len(u.Types)) - hasNull := false - for _, t := range u.Types { - if _, ok := t.(*bindings.Null); ok { - hasNull = true - } else { - nonNull = append(nonNull, t) - } - } - if hasNull && len(nonNull) == 1 { - return exprToZod(nonNull[0], selfName) + ".nullable()" - } - if !hasNull && len(nonNull) == 1 { - return exprToZod(nonNull[0], selfName) - } - parts := make([]string, 0, len(u.Types)) - for _, t := range u.Types { - parts = append(parts, exprToZod(t, selfName)) - } - return fmt.Sprintf("z.union([%s])", strings.Join(parts, ", ")) -} - -func objectLiteralToZod(tl *bindings.TypeLiteralNode, selfName string) string { - var b strings.Builder - b.WriteString("z.object({\n") - for _, f := range tl.Members { - zodType := exprToZod(f.Type, selfName) - if f.QuestionToken { - zodType += ".optional()" - } - b.WriteString(fmt.Sprintf(" %s: %s,\n", f.Name, zodType)) - } - b.WriteString(" })") - return b.String() -} - -func intersectionToZod(inter *bindings.TypeIntersection, selfName string) string { - if len(inter.Types) == 0 { - return "z.unknown()" - } - if len(inter.Types) == 1 { - return exprToZod(inter.Types[0], selfName) - } - parts := make([]string, 0, len(inter.Types)) - for _, t := range inter.Types { - parts = append(parts, exprToZod(t, selfName)) - } - result := parts[0] - for _, p := range parts[1:] { - result = fmt.Sprintf("z.intersection(%s, %s)", result, p) - } - return result -} - -func isStringLiteralUnion(u *bindings.UnionType) bool { - if len(u.Types) == 0 { - return false - } - for _, t := range u.Types { - lit, ok := t.(*bindings.LiteralType) - if !ok { - return false - } - if _, ok := lit.Value.(string); !ok { - return false - } - } - return true -} - -func schemaName(typeName string) string { - return typeName + "Schema" -} diff --git a/scripts/typegen/zod/zod.go b/scripts/typegen/zod/zod.go new file mode 100644 index 0000000..cc11e3e --- /dev/null +++ b/scripts/typegen/zod/zod.go @@ -0,0 +1,658 @@ +// Package zod converts the guts intermediate TypeScript AST into Zod v4 +// schema declarations. +// +// The package exposes a single mutation, AsSchemas, that walks every +// Interface and Alias in a *guts.Typescript and replaces each one with: +// +// - a VariableStatement for `const FooSchema = z.;`, and +// - an Alias for `type Foo = z.infer;`. +// +// It also injects `import { z } from "zod"` so the generated file is +// self-contained. +// +// # Ordering +// +// Zod schemas must be declared before any other schema references them +// because `const` bindings are not hoisted. AsSchemas does not reorder +// nodes itself; the caller is expected to pass SortByDependencies to +// Typescript.SerializeInOrder: +// +// ts.ApplyMutations(zod.AsSchemas) +// out, err := ts.SerializeInOrder(zod.SortByDependencies) +// +// SortByDependencies performs a Kahn's-algorithm topological sort over +// the schema VariableStatements, then pairs each schema with its inferred +// type alias. Self-references inside the same declaration are already +// broken by z.lazy in convertInterface and convertAlias, so the sort +// treats arrow-function bodies as non-dependencies. +// +// # Pipeline +// +// AsSchemas composes with the rest of the config mutations. The intended +// pipeline is: +// +// ts.ApplyMutations( +// config.EnumAsTypes, // int and string enums -> union of literals +// config.SimplifyOmitEmpty, // omitempty -> drop null, keep optional +// zod.AsSchemas, // rewrite Interface/Alias into Zod +// config.ExportTypes, // add `export` to the new declarations +// ) +// +// Other mutations that walk Interface or Alias (ExportTypes, ReadOnly, +// etc.) should run after AsSchemas because the originals are replaced. +package zod + +import ( + "sort" + "strings" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" + "github.com/coder/guts/bindings/walk" +) + +// AsSchemas is the mutation entry point. It walks ts.typescriptNodes and +// rewrites each Interface and Alias into a VariableStatement + Alias pair +// expressed in Zod, and appends `import { z } from "zod"`. +func AsSchemas(ts *guts.Typescript) { + ts.AppendImport(&bindings.ImportDeclaration{ + Module: "zod", + Named: []*bindings.ImportSpecifier{{Name: "z"}}, + }) + + // Collect keys before mutating so the map iteration is not invalidated + // when we Replace and Set during conversion. + var keys []string + ts.ForEach(func(name string, _ bindings.Node) { + keys = append(keys, name) + }) + + for _, key := range keys { + node, ok := ts.Node(key) + if !ok { + continue + } + switch n := node.(type) { + case *bindings.Interface: + convertInterface(ts, key, n) + case *bindings.Alias: + convertAlias(ts, key, n) + } + } +} + +// schemaSuffix is the suffix appended to a type name to produce its +// schema binding. `Foo` becomes `FooSchema`. +const schemaSuffix = "Schema" + +// schemaIdent returns the Identifier for the schema binding paired with a +// type. `Foo` becomes `FooSchema`, with Package and Prefix preserved so +// cross-package disambiguation flows through .Ref() to the emitted name. +func schemaIdent(typeName bindings.Identifier) bindings.Identifier { + return bindings.Identifier{ + Name: typeName.Name + schemaSuffix, + Package: typeName.Package, + Prefix: typeName.Prefix, + } +} + +// inferAlias builds `type = z.infer>` for a +// single converted declaration. +func inferAlias(typeName bindings.Identifier) *bindings.Alias { + return &bindings.Alias{ + Name: typeName, + Type: &bindings.ReferenceType{ + Name: bindings.Identifier{Name: "z.infer"}, + Arguments: []bindings.ExpressionType{ + &bindings.TypeQuery{Name: schemaIdent(typeName)}, + }, + }, + } +} + +// constSchema builds `const = ` with no +// modifiers. The Export mutation, if applied afterwards, adds `export`. +func constSchema(schemaName bindings.Identifier, initializer bindings.ExpressionType) *bindings.VariableStatement { + return &bindings.VariableStatement{ + Modifiers: []bindings.Modifier{}, + Declarations: &bindings.VariableDeclarationList{ + Flags: bindings.NodeFlagsConstant, + Declarations: []*bindings.VariableDeclaration{ + { + Name: schemaName, + Initializer: initializer, + }, + }, + }, + } +} + +// zMethod builds `z.(args...)` as an expression. It is the most +// common shape Zod schemas need. +func zMethod(name string, args ...bindings.ExpressionType) *bindings.CallExpression { + return &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: bindings.Identifier{Name: "z"}}, + Name: name, + }, + Arguments: args, + } +} + +// chain wraps `.()` to extend a schema with a refinement +// like `.optional()` or `.nullable()`. +func chain(expr bindings.ExpressionType, method string) *bindings.CallExpression { + return &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: expr, + Name: method, + }, + } +} + +// converter holds the per-declaration conversion state. Methods on +// converter recurse through a TypeScript type expression and emit the +// equivalent Zod expression with access to the surrounding type's name +// (so self-references can become z.lazy) and to its generic type +// parameters (so a reference to a type parameter becomes z.unknown). +type converter struct { + self bindings.Identifier + typeParams map[string]bool +} + +// newConverter builds a converter for one declaration. params may be nil +// when the declaration has no generic parameters. +func newConverter(self bindings.Identifier, params []*bindings.TypeParameter) *converter { + tp := make(map[string]bool, len(params)) + for _, p := range params { + tp[p.Name.Ref()] = true + } + return &converter{self: self, typeParams: tp} +} + +// convertInterface rewrites an Interface into a schema VariableStatement +// plus an inferred type alias. The original key in ts.typescriptNodes is +// reused for the alias; the schema is added under Schema. +func convertInterface(ts *guts.Typescript, key string, iface *bindings.Interface) { + typeName := iface.Name + schemaName := schemaIdent(typeName) + c := newConverter(typeName, iface.Parameters) + + objLit := c.buildFieldsObject(iface.Fields) + + var initializer bindings.ExpressionType + if base, ok := heritageBase(iface); ok { + // BaseSchema.extend({...}) + initializer = &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: schemaIdent(base)}, + Name: "extend", + }, + Arguments: []bindings.ExpressionType{objLit}, + } + } else { + // z.object({...}) + initializer = zMethod("object", objLit) + } + + ts.ReplaceNode(key, inferAlias(typeName)) + _ = ts.SetNode(schemaName.Ref(), constSchema(schemaName, initializer)) +} + +// convertAlias rewrites an Alias into a schema VariableStatement plus an +// inferred type alias. +func convertAlias(ts *guts.Typescript, key string, alias *bindings.Alias) { + typeName := alias.Name + schemaName := schemaIdent(typeName) + c := newConverter(typeName, alias.Parameters) + + var initializer bindings.ExpressionType + if union, ok := alias.Type.(*bindings.UnionType); ok && isStringLiteralUnion(union) { + initializer = zMethod("enum", stringLiteralArray(union)) + } else { + initializer = c.exprToZod(alias.Type) + } + + ts.ReplaceNode(key, inferAlias(typeName)) + _ = ts.SetNode(schemaName.Ref(), constSchema(schemaName, initializer)) +} + +// heritageBase returns the single heritage base of an Interface as an +// Identifier, if any. Zod's `.extend()` only models single inheritance, +// so multiple heritage clauses cause a panic to surface the mismatch +// rather than silently dropping one. +func heritageBase(iface *bindings.Interface) (bindings.Identifier, bool) { + var base bindings.Identifier + found := false + for _, h := range iface.Heritage { + for _, arg := range h.Args { + ident, ok := heritageArgIdent(arg) + if !ok { + continue + } + if found { + panic("zod: multiple heritage bases on " + iface.Name.Ref() + " (Zod has no multiple inheritance)") + } + base = ident + found = true + } + } + return base, found +} + +// heritageArgIdent unwraps a heritage argument to the underlying +// Identifier when it is a plain type reference. Other shapes are not +// modeled and return false. +func heritageArgIdent(arg bindings.ExpressionType) (bindings.Identifier, bool) { + switch n := arg.(type) { + case *bindings.ExpressionWithTypeArguments: + if rt, ok := n.Expression.(*bindings.ReferenceType); ok { + return rt.Name, true + } + case *bindings.ReferenceType: + return n.Name, true + } + return bindings.Identifier{}, false +} + +// buildFieldsObject collects an Interface's fields into a single +// ObjectLiteralExpression whose values are zod expressions. +func (c *converter) buildFieldsObject(fields []*bindings.PropertySignature) *bindings.ObjectLiteralExpression { + props := make([]*bindings.PropertyAssignment, 0, len(fields)) + for _, f := range fields { + expr := c.exprToZod(f.Type) + if f.QuestionToken { + expr = chain(expr, "optional") + } + props = append(props, &bindings.PropertyAssignment{ + Name: f.Name, + Initializer: expr, + }) + } + return &bindings.ObjectLiteralExpression{Properties: props} +} + +// isStringLiteralUnion reports whether every member of a union is a +// string literal. Such unions become z.enum([...]) rather than +// z.union([z.literal(...), ...]) for readability. +func isStringLiteralUnion(u *bindings.UnionType) bool { + if len(u.Types) == 0 { + return false + } + for _, t := range u.Types { + lit, ok := t.(*bindings.LiteralType) + if !ok { + return false + } + if _, ok := lit.Value.(string); !ok { + return false + } + } + return true +} + +// stringLiteralArray collects the string values from a string-literal +// union into an ArrayLiteralType suitable for `z.enum([...])`. +func stringLiteralArray(u *bindings.UnionType) *bindings.ArrayLiteralType { + elems := make([]bindings.ExpressionType, 0, len(u.Types)) + for _, t := range u.Types { + if lit, ok := t.(*bindings.LiteralType); ok { + elems = append(elems, &bindings.LiteralType{Value: lit.Value}) + } + } + return &bindings.ArrayLiteralType{Elements: elems} +} + +// exprToZod recursively converts a TypeScript type expression into the +// equivalent Zod schema expression. References back to the surrounding +// type use z.lazy() to avoid reference-before-declaration errors. +func (c *converter) exprToZod(expr bindings.ExpressionType) bindings.ExpressionType { + if expr == nil { + return zMethod("unknown") + } + switch e := expr.(type) { + case *bindings.LiteralKeyword: + return keywordToZod(e) + case *bindings.LiteralType: + return zMethod("literal", &bindings.LiteralType{Value: e.Value}) + case *bindings.ReferenceType: + return c.referenceToZod(e) + case *bindings.ArrayType: + return zMethod("array", c.exprToZod(e.Node)) + case *bindings.TupleType: + // Tuples are emitted as arrays today. A future variant could + // switch on TupleType.Length to emit a true z.tuple(). + return zMethod("array", c.exprToZod(e.Node)) + case *bindings.UnionType: + return c.unionToZod(e) + case *bindings.Null: + return zMethod("null") + case *bindings.TypeLiteralNode: + return c.typeLiteralToZod(e) + case *bindings.TypeIntersection: + return c.intersectionToZod(e) + case *bindings.OperatorNodeType: + // readonly/keyof/unique wrappers do not affect the Zod schema; + // unwrap and emit the inner type directly. + return c.exprToZod(e.Type) + default: + return zMethod("unknown") + } +} + +// keywordToZod maps a TypeScript keyword to its z.() form. +func keywordToZod(kw *bindings.LiteralKeyword) bindings.ExpressionType { + switch *kw { + case bindings.KeywordString: + return zMethod("string") + case bindings.KeywordNumber: + return zMethod("number") + case bindings.KeywordBoolean: + return zMethod("boolean") + case bindings.KeywordAny, bindings.KeywordUnknown: + return zMethod("unknown") + case bindings.KeywordVoid, bindings.KeywordUndefined: + return zMethod("undefined") + case bindings.KeywordNever: + return zMethod("never") + default: + return zMethod("unknown") + } +} + +// referenceToZod converts a type reference to a Zod expression. +// +// Resolution order: +// +// 1. References to a generic type parameter on the surrounding +// declaration fall back to z.unknown(). Zod has no runtime +// equivalent for an unbound type parameter. +// 2. Record becomes z.record(K, V). +// 3. Other utility-type generics (Omit, Pick, Partial, Required) are not +// yet modeled and fall back to z.unknown(). +// 4. A reference to the surrounding declaration emits z.lazy to break +// the value-position cycle. +// 5. Anything else emits the paired `Schema` identifier. +func (c *converter) referenceToZod(ref *bindings.ReferenceType) bindings.ExpressionType { + name := ref.Name.Ref() + + if c.typeParams[name] { + return zMethod("unknown") + } + + if name == "Record" && len(ref.Arguments) == 2 { + return zMethod("record", + c.exprToZod(ref.Arguments[0]), + c.exprToZod(ref.Arguments[1]), + ) + } + switch name { + case "Omit", "Pick", "Partial", "Required": + return zMethod("unknown") + } + + if name == c.self.Ref() { + // z.lazy((): z.ZodType => SelfSchema) breaks a value-position + // reference cycle without making the surrounding type lazy. + return zMethod("lazy", &bindings.ArrowFunction{ + ReturnType: bindings.Reference(bindings.Identifier{Name: "z.ZodType"}), + Body: &bindings.IdentifierExpression{Name: schemaIdent(ref.Name)}, + }) + } + + return &bindings.IdentifierExpression{Name: schemaIdent(ref.Name)} +} + +// unionToZod handles three union shapes: +// - T | null collapses to .nullable(). +// - A union with a single non-null member emits just that member; the +// null is dropped because the surrounding optional marker covers it. +// - Anything else becomes z.union([...]). +func (c *converter) unionToZod(u *bindings.UnionType) bindings.ExpressionType { + nonNull := make([]bindings.ExpressionType, 0, len(u.Types)) + hasNull := false + for _, t := range u.Types { + if _, ok := t.(*bindings.Null); ok { + hasNull = true + continue + } + nonNull = append(nonNull, t) + } + + if hasNull && len(nonNull) == 1 { + return chain(c.exprToZod(nonNull[0]), "nullable") + } + if !hasNull && len(nonNull) == 1 { + return c.exprToZod(nonNull[0]) + } + + args := make([]bindings.ExpressionType, 0, len(u.Types)) + for _, t := range u.Types { + args = append(args, c.exprToZod(t)) + } + return zMethod("union", &bindings.ArrayLiteralType{Elements: args}) +} + +// typeLiteralToZod inlines an object type literal as a `z.object({...})` +// expression. Members carry through the same optional-marker handling +// as top-level interface fields. +func (c *converter) typeLiteralToZod(tl *bindings.TypeLiteralNode) bindings.ExpressionType { + props := make([]*bindings.PropertyAssignment, 0, len(tl.Members)) + for _, m := range tl.Members { + expr := c.exprToZod(m.Type) + if m.QuestionToken { + expr = chain(expr, "optional") + } + props = append(props, &bindings.PropertyAssignment{ + Name: m.Name, + Initializer: expr, + }) + } + return zMethod("object", &bindings.ObjectLiteralExpression{Properties: props}) +} + +// intersectionToZod folds an intersection into a left-associative chain +// of z.intersection(a, b) calls so the schema preserves intersection +// semantics for arbitrary member counts. +func (c *converter) intersectionToZod(it *bindings.TypeIntersection) bindings.ExpressionType { + switch len(it.Types) { + case 0: + return zMethod("unknown") + case 1: + return c.exprToZod(it.Types[0]) + } + out := c.exprToZod(it.Types[0]) + for _, t := range it.Types[1:] { + out = zMethod("intersection", out, c.exprToZod(t)) + } + return out +} + +// SortByDependencies returns the nodes from a Typescript map ordered so +// that each schema's dependencies are emitted before the schema itself. +// It is intended to be passed to Typescript.SerializeInOrder when +// emitting Zod output: +// +// out, err := ts.SerializeInOrder(zod.SortByDependencies) +// +// The algorithm: +// +// 1. Partition the input into schema VariableStatements (keys ending in +// "Schema"), their paired type aliases, and other nodes. +// 2. Build a dependency graph by scanning each schema's initializer for +// IdentifierExpression references that name another schema. Bodies +// of ArrowFunction nodes are skipped, so z.lazy(() => OtherSchema) +// does not create a hard dependency on OtherSchema. This lets users +// break cross-type cycles manually with z.lazy. +// 3. Topologically sort using Kahn's algorithm with alphabetical +// tie-breaking so the output is deterministic. +// 4. Anything left in a cycle is appended in alphabetical order. The +// resulting TypeScript will compile only if those nodes use z.lazy +// to defer their references. +// +// Each schema is emitted immediately followed by its paired alias so the +// `type Foo = z.infer` line stays next to its schema. +// Other nodes (anything not matching the schema-plus-alias shape) are +// emitted first in alphabetical order so they do not interleave with +// the sorted schemas. +func SortByDependencies(nodes map[string]bindings.Node) []bindings.Node { + schemaKeys, aliasOf, otherKeys := partitionNodes(nodes) + + indegree, outEdges := buildDependencyGraph(nodes, schemaKeys) + + sorted := kahnSort(schemaKeys, indegree, outEdges) + + out := make([]bindings.Node, 0, len(nodes)) + for _, k := range otherKeys { + out = append(out, nodes[k]) + } + for _, k := range sorted { + out = append(out, nodes[k]) + if alias, ok := aliasOf[k]; ok { + out = append(out, nodes[alias]) + } + } + return out +} + +// partitionNodes splits a Typescript node map into three groups: +// - schemaKeys: keys of VariableStatement nodes ending in "Schema", +// sorted alphabetically for deterministic seed order. +// - aliasOf: a map from each schema key to its paired alias key +// (e.g. "FooSchema" -> "Foo"), present only when both exist. +// - otherKeys: all remaining keys, sorted alphabetically. +// +// Aliases that are paired with a schema are not included in otherKeys +// because SortByDependencies emits them next to their schema. +func partitionNodes(nodes map[string]bindings.Node) (schemaKeys []string, aliasOf map[string]string, otherKeys []string) { + aliasOf = map[string]string{} + schemaSet := map[string]bool{} + for k, n := range nodes { + if _, ok := n.(*bindings.VariableStatement); !ok { + continue + } + if !strings.HasSuffix(k, schemaSuffix) { + continue + } + schemaSet[k] = true + schemaKeys = append(schemaKeys, k) + aliasName := strings.TrimSuffix(k, schemaSuffix) + if _, ok := nodes[aliasName]; ok { + aliasOf[k] = aliasName + } + } + pairedAlias := map[string]bool{} + for _, v := range aliasOf { + pairedAlias[v] = true + } + for k := range nodes { + if schemaSet[k] || pairedAlias[k] { + continue + } + otherKeys = append(otherKeys, k) + } + sort.Strings(schemaKeys) + sort.Strings(otherKeys) + return schemaKeys, aliasOf, otherKeys +} + +// buildDependencyGraph walks each schema's initializer and records edges +// from dependency to dependent. outEdges[dep] lists the schemas that +// must be emitted after dep, and indegree[schema] counts how many +// schemas it depends on. ArrowFunction bodies are skipped so z.lazy +// references do not contribute hard dependencies. +func buildDependencyGraph(nodes map[string]bindings.Node, schemaKeys []string) (indegree map[string]int, outEdges map[string][]string) { + schemaSet := make(map[string]bool, len(schemaKeys)) + for _, k := range schemaKeys { + schemaSet[k] = true + } + + indegree = make(map[string]int, len(schemaKeys)) + outEdges = make(map[string][]string, len(schemaKeys)) + for _, k := range schemaKeys { + indegree[k] = 0 + } + for _, k := range schemaKeys { + vs := nodes[k].(*bindings.VariableStatement) + deps := collectSchemaDeps(vs, schemaSet, k) + for dep := range deps { + outEdges[dep] = append(outEdges[dep], k) + indegree[k]++ + } + } + for k := range outEdges { + sort.Strings(outEdges[k]) + } + return indegree, outEdges +} + +// kahnSort runs Kahn's algorithm with alphabetical tie-breaking. Nodes +// remaining in a cycle after the queue drains are appended in +// alphabetical order. Callers must still break cross-type cycles with +// z.lazy; this fallback only keeps Serialize from dropping nodes. +func kahnSort(schemaKeys []string, indegree map[string]int, outEdges map[string][]string) []string { + queue := make([]string, 0, len(schemaKeys)) + for _, k := range schemaKeys { + if indegree[k] == 0 { + queue = append(queue, k) + } + } + sort.Strings(queue) + + sorted := make([]string, 0, len(schemaKeys)) + for len(queue) > 0 { + head := queue[0] + queue = queue[1:] + sorted = append(sorted, head) + for _, dep := range outEdges[head] { + indegree[dep]-- + if indegree[dep] == 0 { + queue = append(queue, dep) + } + } + sort.Strings(queue) + } + + placed := make(map[string]bool, len(sorted)) + for _, k := range sorted { + placed[k] = true + } + for _, k := range schemaKeys { + if !placed[k] { + sorted = append(sorted, k) + } + } + return sorted +} + +// collectSchemaDeps returns the schema keys referenced as +// IdentifierExpression inside a VariableStatement, excluding the schema +// itself and excluding references inside ArrowFunction bodies (which is +// how z.lazy is emitted). +func collectSchemaDeps(vs *bindings.VariableStatement, schemas map[string]bool, self string) map[string]bool { + deps := map[string]bool{} + walk.Walk(&depVisitor{deps: deps, schemas: schemas, self: self}, vs) + return deps +} + +type depVisitor struct { + deps map[string]bool + schemas map[string]bool + self string +} + +func (d *depVisitor) Visit(node bindings.Node) walk.Visitor { + if _, ok := node.(*bindings.ArrowFunction); ok { + // Skip arrow function bodies. z.lazy(() => Other) defers its + // reference at runtime, so it should not force Other to be + // declared first. + return nil + } + if ident, ok := node.(*bindings.IdentifierExpression); ok { + name := ident.Name.Ref() + if name != d.self && d.schemas[name] { + d.deps[name] = true + } + } + return d +} diff --git a/scripts/typegen/zod/zod_test.go b/scripts/typegen/zod/zod_test.go new file mode 100644 index 0000000..fe2c49d --- /dev/null +++ b/scripts/typegen/zod/zod_test.go @@ -0,0 +1,548 @@ +package zod_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" + "github.com/coder/agents-chat-action/scripts/typegen/zod" +) + +// newTS constructs a fresh *guts.Typescript with no Go-derived nodes so a +// test can install its own minimal fixture with SetNode. +func newTS(t *testing.T) *guts.Typescript { + t.Helper() + gen, err := guts.NewGolangParser() + require.NoError(t, err) + ts, err := gen.ToTypescript() + require.NoError(t, err) + return ts +} + +func runZod(t *testing.T, ts *guts.Typescript) string { + t.Helper() + ts.ApplyMutations(zod.AsSchemas) + out, err := ts.Serialize() + require.NoError(t, err) + return out +} + +// runZodInOrder runs AsSchemas and then serializes with +// SortByDependencies so tests can assert ordering as well as content. +func runZodInOrder(t *testing.T, ts *guts.Typescript) string { + t.Helper() + ts.ApplyMutations(zod.AsSchemas) + out, err := ts.SerializeInOrder(zod.SortByDependencies) + require.NoError(t, err) + return out +} + +func ident(name string) bindings.Identifier { return bindings.Identifier{Name: name} } + +func kw(k bindings.LiteralKeyword) *bindings.LiteralKeyword { return &k } + +// TestObjectFields exercises the keyword-to-z mappings inside an interface +// (z.string, z.number, z.boolean) and the optional refinement attached to +// fields with a question token. +func TestObjectFields(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("User", &bindings.Interface{ + Name: ident("User"), + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + {Name: "name", Type: kw(bindings.KeywordString), QuestionToken: true}, + {Name: "age", Type: kw(bindings.KeywordNumber)}, + {Name: "active", Type: kw(bindings.KeywordBoolean)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "const UserSchema = z.object(") + require.Contains(t, out, "id: z.string()") + require.Contains(t, out, "name: z.string().optional()") + require.Contains(t, out, "age: z.number()") + require.Contains(t, out, "active: z.boolean()") + require.Contains(t, out, "type User = z.infer") +} + +// TestStringLiteralUnionBecomesEnum checks that an Alias whose Type is a +// union of string literals collapses to z.enum([...]) rather than the more +// verbose z.union of z.literal calls. +func TestStringLiteralUnionBecomesEnum(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Status", &bindings.Alias{ + Name: ident("Status"), + Type: bindings.Union( + &bindings.LiteralType{Value: "active"}, + &bindings.LiteralType{Value: "inactive"}, + &bindings.LiteralType{Value: "banned"}, + ), + })) + + out := runZod(t, ts) + require.Contains(t, out, "const StatusSchema = z.enum(") + require.Contains(t, out, `"active"`) + require.Contains(t, out, `"inactive"`) + require.Contains(t, out, `"banned"`) + require.NotContains(t, out, "z.literal", "string-literal union must not fall back to z.literal+z.union") +} + +// TestNullableField checks the union-with-null collapse: `T | null` becomes +// `T.nullable()` instead of `z.union([T, z.null()])`. +func TestNullableField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "error", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + }, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "error: z.string().nullable()") + require.NotContains(t, out, "z.union", "T|null must not emit z.union") +} + +// TestOptionalNullableField checks that QuestionToken and a `| null` union +// both apply, in nullable-then-optional order. +func TestOptionalNullableField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "parent_id", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + QuestionToken: true, + }, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "parent_id: z.string().nullable().optional()") +} + +// TestArrayField checks that a TypeScript array maps to z.array. +func TestArrayField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + {Name: "tags", Type: bindings.Array(kw(bindings.KeywordString))}, + }, + })) + + require.Contains(t, runZod(t, ts), "tags: z.array(z.string())") +} + +// TestReferenceField checks that a bare type reference resolves to the +// paired Schema identifier. +func TestReferenceField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("ErrorInfo", &bindings.Interface{ + Name: ident("ErrorInfo"), + Fields: []*bindings.PropertySignature{ + {Name: "message", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Chat", &bindings.Interface{ + Name: ident("Chat"), + Fields: []*bindings.PropertySignature{ + {Name: "last_error", Type: bindings.Reference(ident("ErrorInfo"))}, + }, + })) + + require.Contains(t, runZod(t, ts), "last_error: ErrorInfoSchema") +} + +// TestRecordField checks that Record maps to z.record(K, V). +func TestRecordField(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "labels", + Type: bindings.Reference(ident("Record"), + kw(bindings.KeywordString), + kw(bindings.KeywordString), + ), + }, + }, + })) + + require.Contains(t, runZod(t, ts), "labels: z.record(z.string(), z.string())") +} + +// TestInlineObjectLiteral checks that an inline object type produces an +// inline z.object expression rather than a free-standing schema. +func TestInlineObjectLiteral(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Outer", &bindings.Interface{ + Name: ident("Outer"), + Fields: []*bindings.PropertySignature{ + { + Name: "nested", + Type: &bindings.TypeLiteralNode{ + Members: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordNumber)}, + {Name: "y", Type: kw(bindings.KeywordNumber)}, + }, + }, + }, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "nested: z.object(") + require.Contains(t, out, "x: z.number()") + require.Contains(t, out, "y: z.number()") +} + +// TestSelfReferenceLazy checks that a field whose type references the +// enclosing type wraps the schema in z.lazy() so the value-position +// reference does not fire before the binding exists. +func TestSelfReferenceLazy(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Tree", &bindings.Interface{ + Name: ident("Tree"), + Fields: []*bindings.PropertySignature{ + {Name: "value", Type: kw(bindings.KeywordNumber)}, + {Name: "children", Type: bindings.Array(bindings.Reference(ident("Tree")))}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "children: z.array(z.lazy((): z.ZodType => TreeSchema))") +} + +// TestHeritageExtend checks that single-base heritage maps to +// `BaseSchema.extend({...})`. +func TestHeritageExtend(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Base", &bindings.Interface{ + Name: ident("Base"), + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Child", &bindings.Interface{ + Name: ident("Child"), + Heritage: []*bindings.HeritageClause{ + {Args: []bindings.ExpressionType{bindings.Reference(ident("Base"))}}, + }, + Fields: []*bindings.PropertySignature{ + {Name: "extra", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "const ChildSchema = BaseSchema.extend(") + require.Contains(t, out, "extra: z.string()") +} + +// TestAppendsZodImport pins that AsSchemas appends the zod import so the +// generated file is self-contained without the caller having to know to +// add config.InjectImport("zod", "z") separately. +func TestAppendsZodImport(t *testing.T) { + t.Parallel() + + ts := newTS(t) + out := runZod(t, ts) + require.Contains(t, out, `import { z } from "zod";`) +} + +// TestMixedUnionStaysUnion checks that a union that is not all string +// literals and not just T|null keeps the z.union shape. +func TestMixedUnionStaysUnion(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("MixedUnion", &bindings.Alias{ + Name: ident("MixedUnion"), + Type: bindings.Union( + kw(bindings.KeywordString), + kw(bindings.KeywordNumber), + ), + })) + + out := runZod(t, ts) + require.Contains(t, out, "z.union(") + require.Contains(t, out, "z.string()") + require.Contains(t, out, "z.number()") +} + +// TestSingleMemberUnionUnwraps checks the single-non-null-member shortcut: +// `union { T }` collapses to just `T` rather than `z.union([T])`. +func TestSingleMemberUnionUnwraps(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Wrapped", &bindings.Alias{ + Name: ident("Wrapped"), + Type: bindings.Union(kw(bindings.KeywordString)), + })) + + out := runZod(t, ts) + require.Contains(t, out, "const WrappedSchema = z.string()") + require.NotContains(t, out, "z.union", "single-member union must not wrap in z.union") +} + +// TestPrefixedReference pins the cross-package prefix passthrough. An +// Identifier with a Prefix must flow through schemaIdent and emit the +// prefixed Schema name in both the schema declaration and the reference. +func TestPrefixedReference(t *testing.T) { + t.Parallel() + + prefixed := bindings.Identifier{Name: "Item", Prefix: "External"} + + ts := newTS(t) + require.NoError(t, ts.SetNode(prefixed.Ref(), &bindings.Interface{ + Name: prefixed, + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Holder", &bindings.Interface{ + Name: ident("Holder"), + Fields: []*bindings.PropertySignature{ + {Name: "item", Type: bindings.Reference(prefixed)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "const ExternalItemSchema = z.object(") + require.Contains(t, out, "item: ExternalItemSchema") + require.Contains(t, out, "type ExternalItem = z.infer", + strings.TrimSpace(out)) +} + +// TestGenericTypeParameterFallsBackToUnknown checks that a reference to a +// generic type parameter on the surrounding declaration emits z.unknown(). +// Zod has no runtime equivalent for an unbound type parameter, so the +// fallback is the most useful schema that still type-checks. +func TestGenericTypeParameterFallsBackToUnknown(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("IDPSyncMapping", &bindings.Interface{ + Name: ident("IDPSyncMapping"), + Parameters: []*bindings.TypeParameter{ + {Name: ident("ResourceIdType")}, + }, + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: bindings.Reference(ident("ResourceIdType"))}, + {Name: "name", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZod(t, ts) + require.Contains(t, out, "id: z.unknown()", + "reference to type parameter ResourceIdType must fall back to z.unknown()") + require.NotContains(t, out, "ResourceIdTypeSchema", + "a non-existent ResourceIdTypeSchema reference must not be emitted") + require.Contains(t, out, "name: z.string()") +} + +// TestGenericTypeParameterOnAlias is the alias-side equivalent of +// TestGenericTypeParameterFallsBackToUnknown. +func TestGenericTypeParameterOnAlias(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Wrapper", &bindings.Alias{ + Name: ident("Wrapper"), + Parameters: []*bindings.TypeParameter{ + {Name: ident("T")}, + }, + Type: bindings.Reference(ident("T")), + })) + + out := runZod(t, ts) + require.Contains(t, out, "const WrapperSchema = z.unknown()") + require.NotContains(t, out, "TSchema") +} + +// TestSortByDependenciesForwardReference pins the ordering guarantee: +// a schema that references another schema must be emitted after it, +// regardless of alphabetical order. +func TestSortByDependenciesForwardReference(t *testing.T) { + t.Parallel() + + ts := newTS(t) + // Define Foo first; it references Bar via heritage. Alphabetically + // FooSchema would precede BarSchema, but topologically it must + // follow it. + require.NoError(t, ts.SetNode("Foo", &bindings.Interface{ + Name: ident("Foo"), + Heritage: []*bindings.HeritageClause{ + {Args: []bindings.ExpressionType{bindings.Reference(ident("Bar"))}}, + }, + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Bar", &bindings.Interface{ + Name: ident("Bar"), + Fields: []*bindings.PropertySignature{ + {Name: "y", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZodInOrder(t, ts) + barIdx := strings.Index(out, "const BarSchema") + fooIdx := strings.Index(out, "const FooSchema") + require.NotEqual(t, -1, barIdx, "BarSchema must be emitted") + require.NotEqual(t, -1, fooIdx, "FooSchema must be emitted") + require.Less(t, barIdx, fooIdx, + "BarSchema must precede FooSchema because Foo extends Bar") +} + +// TestSortByDependenciesAlphabeticalTiebreak verifies that independent +// schemas are emitted alphabetically. This keeps output deterministic +// when there are no dependency edges between two schemas. +func TestSortByDependenciesAlphabeticalTiebreak(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Beta", &bindings.Interface{ + Name: ident("Beta"), + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + require.NoError(t, ts.SetNode("Alpha", &bindings.Interface{ + Name: ident("Alpha"), + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZodInOrder(t, ts) + alphaIdx := strings.Index(out, "const AlphaSchema") + betaIdx := strings.Index(out, "const BetaSchema") + require.NotEqual(t, -1, alphaIdx) + require.NotEqual(t, -1, betaIdx) + require.Less(t, alphaIdx, betaIdx, + "independent schemas must be emitted alphabetically") +} + +// TestSortByDependenciesPairsAlias verifies that each schema's inferred +// type alias is emitted immediately after the schema itself, so the +// pair stays visually grouped in the output. +func TestSortByDependenciesPairsAlias(t *testing.T) { + t.Parallel() + + ts := newTS(t) + require.NoError(t, ts.SetNode("Foo", &bindings.Interface{ + Name: ident("Foo"), + Fields: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordString)}, + }, + })) + + out := runZodInOrder(t, ts) + schemaIdx := strings.Index(out, "const FooSchema") + aliasIdx := strings.Index(out, "type Foo = z.infer") + require.NotEqual(t, -1, schemaIdx) + require.NotEqual(t, -1, aliasIdx) + require.Less(t, schemaIdx, aliasIdx, + "alias must follow the schema it infers from") + // And no other declaration may sit between the pair. Start scanning + // after the schema's own `const ` so we do not match itself. + between := out[schemaIdx+len("const "):aliasIdx] + require.NotContains(t, between, "const ", "schema and its alias must be adjacent") +} + +// TestSortByDependenciesLazyBreaksCycle exercises the cross-type cycle +// path. Two schemas that reference each other through z.lazy must both +// be emitted; the lazy reference removes the hard dependency edge. +// +// This test simulates what a user would do to break a true cycle: wrap +// each cross-reference in z.lazy. The deps walker skips ArrowFunction +// bodies, so neither schema depends on the other, and Kahn's algorithm +// emits both in alphabetical order. +func TestSortByDependenciesLazyBreaksCycle(t *testing.T) { + t.Parallel() + + ts := newTS(t) + // Hand-build the resulting schemas so we can be sure both + // references are inside ArrowFunctions. + lazyRef := func(target string) bindings.ExpressionType { + return &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: ident("z")}, + Name: "lazy", + }, + Arguments: []bindings.ExpressionType{ + &bindings.ArrowFunction{ + Body: &bindings.IdentifierExpression{Name: ident(target)}, + }, + }, + } + } + makeSchema := func(name string, other string) *bindings.VariableStatement { + return &bindings.VariableStatement{ + Declarations: &bindings.VariableDeclarationList{ + Flags: bindings.NodeFlagsConstant, + Declarations: []*bindings.VariableDeclaration{ + { + Name: ident(name), + Initializer: &bindings.CallExpression{ + Expression: &bindings.PropertyAccessExpression{ + Expression: &bindings.IdentifierExpression{Name: ident("z")}, + Name: "object", + }, + Arguments: []bindings.ExpressionType{ + &bindings.ObjectLiteralExpression{ + Properties: []*bindings.PropertyAssignment{ + {Name: "ref", Initializer: lazyRef(other)}, + }, + }, + }, + }, + }, + }, + }, + } + } + require.NoError(t, ts.SetNode("ASchema", makeSchema("ASchema", "BSchema"))) + require.NoError(t, ts.SetNode("BSchema", makeSchema("BSchema", "ASchema"))) + + out, err := ts.SerializeInOrder(zod.SortByDependencies) + require.NoError(t, err) + aIdx := strings.Index(out, "const ASchema") + bIdx := strings.Index(out, "const BSchema") + require.NotEqual(t, -1, aIdx, "ASchema must be emitted") + require.NotEqual(t, -1, bIdx, "BSchema must be emitted") + // Lazy references should not count as dependencies, so alphabetical + // order is the natural fallback. + require.Less(t, aIdx, bIdx, + "lazy-only references must not create a hard dependency") +} diff --git a/src/codersdk.gen.ts b/src/codersdk.gen.ts index 6828a6f..677f5e8 100644 --- a/src/codersdk.gen.ts +++ b/src/codersdk.gen.ts @@ -1,7 +1,13 @@ -// Code generated by 'make gen'. DO NOT EDIT. +// Code generated by 'guts'. DO NOT EDIT. + import { z } from "zod"; +export const ChatBusyBehaviorSchema = z.enum(["interrupt", "queue"]); + +export type ChatBusyBehavior = z.infer; + export const ChatClientTypeSchema = z.enum(["api", "ui"]); + export type ChatClientType = z.infer; export const ChatDiffStatusSchema = z.object({ @@ -25,6 +31,7 @@ export const ChatDiffStatusSchema = z.object({ refreshed_at: z.string().optional(), stale_at: z.string().optional(), }); + export type ChatDiffStatus = z.infer; export const ChatErrorKindSchema = z.enum([ @@ -37,6 +44,7 @@ export const ChatErrorKindSchema = z.enum([ "timeout", "usage_limit", ]); + export type ChatErrorKind = z.infer; export const ChatErrorSchema = z.object({ @@ -47,6 +55,7 @@ export const ChatErrorSchema = z.object({ retryable: z.boolean(), status_code: z.number().optional(), }); + export type ChatError = z.infer; export const ChatFileMetadataSchema = z.object({ @@ -57,9 +66,31 @@ export const ChatFileMetadataSchema = z.object({ mime_type: z.string(), created_at: z.string(), }); + export type ChatFileMetadata = z.infer; +export const ChatInputPartTypeSchema = z.enum([ + "file", + "file-reference", + "text", +]); + +export type ChatInputPartType = z.infer; + +export const ChatInputPartSchema = z.object({ + type: ChatInputPartTypeSchema, + text: z.string().optional(), + file_id: z.string().optional(), + file_name: z.string().optional(), + start_line: z.number().optional(), + end_line: z.number().optional(), + content: z.string().optional(), +}); + +export type ChatInputPart = z.infer; + export const ChatPlanModeSchema = z.enum(["plan"]); + export type ChatPlanMode = z.infer; export const ChatStatusSchema = z.enum([ @@ -71,6 +102,7 @@ export const ChatStatusSchema = z.enum([ "running", "waiting", ]); + export type ChatStatus = z.infer; export const ChatSchema = z.object({ @@ -104,28 +136,8 @@ export const ChatSchema = z.object({ client_type: ChatClientTypeSchema, children: z.array(z.lazy((): z.ZodType => ChatSchema)), }); -export type Chat = z.infer; - -export const ChatBusyBehaviorSchema = z.enum(["interrupt", "queue"]); -export type ChatBusyBehavior = z.infer; -export const ChatInputPartTypeSchema = z.enum([ - "file", - "file-reference", - "text", -]); -export type ChatInputPartType = z.infer; - -export const ChatInputPartSchema = z.object({ - type: ChatInputPartTypeSchema, - text: z.string().optional(), - file_id: z.string().optional(), - file_name: z.string().optional(), - start_line: z.number().optional(), - end_line: z.number().optional(), - content: z.string().optional(), -}); -export type ChatInputPart = z.infer; +export type Chat = z.infer; export const CreateChatMessageRequestSchema = z.object({ content: z.array(ChatInputPartSchema), @@ -134,6 +146,7 @@ export const CreateChatMessageRequestSchema = z.object({ busy_behavior: ChatBusyBehaviorSchema.optional(), plan_mode: ChatPlanModeSchema.optional(), }); + export type CreateChatMessageRequest = z.infer< typeof CreateChatMessageRequestSchema >; @@ -143,6 +156,7 @@ export const DynamicToolSchema = z.object({ description: z.string().optional(), input_schema: z.unknown(), }); + export type DynamicTool = z.infer; export const CreateChatRequestSchema = z.object({ @@ -157,6 +171,7 @@ export const CreateChatRequestSchema = z.object({ plan_mode: ChatPlanModeSchema.optional(), client_type: ChatClientTypeSchema.optional(), }); + export type CreateChatRequest = z.infer; export const LoginTypeSchema = z.enum([ @@ -167,6 +182,7 @@ export const LoginTypeSchema = z.enum([ "token", "", ]); + export type LoginType = z.infer; export const MinimalOrganizationSchema = z.object({ @@ -175,6 +191,7 @@ export const MinimalOrganizationSchema = z.object({ display_name: z.string(), icon: z.string(), }); + export type MinimalOrganization = z.infer; export const MinimalUserSchema = z.object({ @@ -183,6 +200,7 @@ export const MinimalUserSchema = z.object({ name: z.string().optional(), avatar_url: z.string().optional(), }); + export type MinimalUser = z.infer; export const OrganizationSchema = MinimalOrganizationSchema.extend({ @@ -191,9 +209,19 @@ export const OrganizationSchema = MinimalOrganizationSchema.extend({ updated_at: z.string(), is_default: z.boolean(), }); + export type Organization = z.infer; +export const SlimRoleSchema = z.object({ + name: z.string(), + display_name: z.string(), + organization_id: z.string().optional(), +}); + +export type SlimRole = z.infer; + export const UserStatusSchema = z.enum(["active", "dormant", "suspended"]); + export type UserStatus = z.infer; export const ReducedUserSchema = MinimalUserSchema.extend({ @@ -206,18 +234,13 @@ export const ReducedUserSchema = MinimalUserSchema.extend({ is_service_account: z.boolean().optional(), theme_preference: z.string().optional(), }); -export type ReducedUser = z.infer; -export const SlimRoleSchema = z.object({ - name: z.string(), - display_name: z.string(), - organization_id: z.string().optional(), -}); -export type SlimRole = z.infer; +export type ReducedUser = z.infer; export const UserSchema = ReducedUserSchema.extend({ organization_ids: z.array(z.string()), roles: z.array(SlimRoleSchema), has_ai_seat: z.boolean(), }); + export type User = z.infer;