Skip to content

Commit ab86eb6

Browse files
committed
refactor(scripts/typegen): replace local Zod serializer with guts/zod
Delete the hand-rolled zod.go serializer and use zod.AsSchemas from github.com/coder/guts/zod (PR coder/guts#85) instead. The mutation rewrites the guts AST to Zod v4 schema nodes, then SerializeInOrder emits only the wanted types in dependency order. guts v1.7.0 -> v1.7.1-0.20260529230818-2f30faf483eb (feat/zod-mutation)
1 parent b3fc81d commit ab86eb6

7 files changed

Lines changed: 56 additions & 532 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ all: gen build fmt lint test
66

77
gen: src/codersdk.gen.ts
88

9-
src/codersdk.gen.ts: scripts/typegen/main.go scripts/typegen/zod.go scripts/typegen/go.mod
9+
src/codersdk.gen.ts: scripts/typegen/main.go scripts/typegen/go.mod
1010
@cd scripts/typegen && go run . > ../../src/codersdk.gen.ts.tmp
1111
@mv src/codersdk.gen.ts.tmp src/codersdk.gen.ts
1212
@bun run format -- src/codersdk.gen.ts || true

scripts/typegen/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ require (
1111
github.com/buger/jsonparser v1.1.2 // indirect
1212
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1313
github.com/coder/coder/v2 v2.34.0-rc.0.0.20260528065010-cfa343e45666 // indirect
14-
github.com/coder/guts v1.7.0 // indirect
14+
github.com/coder/guts v1.7.1-0.20260529230818-2f30faf483eb // indirect
1515
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect
1616
github.com/coder/serpent v0.15.0 // indirect
1717
github.com/coder/websocket v1.8.14 // indirect

scripts/typegen/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ github.com/coder/coder/v2 v2.34.0-rc.0.0.20260528065010-cfa343e45666 h1:imJX12Fi
1616
github.com/coder/coder/v2 v2.34.0-rc.0.0.20260528065010-cfa343e45666/go.mod h1:5UG9p30Gqu3UoUz0fo18JzD5DBQj2S9ArDtbI8yhIr8=
1717
github.com/coder/guts v1.7.0 h1:TaZ/PR9wgN8dlbcckaWV1MxkkuEFZRwSRwBBEm8dYXs=
1818
github.com/coder/guts v1.7.0/go.mod h1:30SShdvpmsauNlsNjECRB5AppScjYk08rf2ZVpH3MFg=
19+
github.com/coder/guts v1.7.1-0.20260529230818-2f30faf483eb h1:MjlXdlmJwVf24NGrdE+2/spUKJlgNeAmNAx7RtobwoI=
20+
github.com/coder/guts v1.7.1-0.20260529230818-2f30faf483eb/go.mod h1:VAC7GjXGIoM747tMmabVQHTzb/ZtAQxGaFCiZE9g/C4=
1921
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
2022
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
2123
github.com/coder/serpent v0.15.0 h1:jobR7DnPsxzEMD0cRiailwlY+4v6HAPS/8emIgBpaIU=

scripts/typegen/main.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import (
1212
"maps"
1313
"os"
1414
"slices"
15-
"strings"
1615

1716
"github.com/coder/guts"
1817
"github.com/coder/guts/bindings"
1918
"github.com/coder/guts/config"
19+
"github.com/coder/guts/zod"
2020
)
2121

2222
// Types the action needs from the Coder API. Only root types that
@@ -63,38 +63,46 @@ func main() {
6363
log.Fatalf("to typescript: %v", err)
6464
}
6565

66+
// Pre-Zod mutations that reshape enums and optional fields.
6667
ts.ApplyMutations(
6768
config.EnumAsTypes,
6869
config.SimplifyOmitEmpty,
6970
)
7071

71-
// Collect all nodes then filter to wanted types plus any
72-
// types they reference that are also in the full set.
72+
// Compute wanted types and dependency order before Zod
73+
// rewrites the AST (collectRefs works on Interface/Alias).
7374
allNodes := make(map[string]bindings.Node)
7475
ts.ForEach(func(name string, node bindings.Node) {
7576
allNodes[name] = node
7677
})
77-
78-
// Resolve transitive references so we don't emit broken
79-
// schema references.
8078
included := resolveTransitive(allNodes, wantedTypes)
79+
order := topoSort(included)
8180

82-
// Topological sort: emit dependencies before dependents.
83-
names := topoSort(included)
84-
85-
var b strings.Builder
86-
b.WriteString("// Code generated by 'make gen'. DO NOT EDIT.\n")
87-
b.WriteString("import { z } from \"zod\";\n\n")
81+
// Rewrite Interface/Alias into Zod schemas, then export.
82+
ts.ApplyMutations(
83+
zod.AsSchemas,
84+
config.ExportTypes,
85+
)
8886

89-
for _, name := range names {
90-
s := serializeNode(name, included[name])
91-
if s != "" {
92-
b.WriteString(s)
93-
b.WriteString("\n")
87+
output, err := ts.SerializeInOrder(func(nodes map[string]bindings.Node) []bindings.Node {
88+
var result []bindings.Node
89+
for _, name := range order {
90+
// AsSchemas creates FooSchema (VariableStatement) and
91+
// Foo (Alias with z.infer<typeof FooSchema>).
92+
if schema, ok := nodes[name+"Schema"]; ok {
93+
result = append(result, schema)
94+
}
95+
if typeNode, ok := nodes[name]; ok {
96+
result = append(result, typeNode)
97+
}
9498
}
99+
return result
100+
})
101+
if err != nil {
102+
log.Fatalf("serialize: %v", err)
95103
}
96104

97-
_, _ = fmt.Fprint(os.Stdout, b.String())
105+
_, _ = fmt.Fprint(os.Stdout, output)
98106
}
99107

100108
// resolveTransitive starts from the seeds and pulls in any

scripts/typegen/main_test.go

Lines changed: 2 additions & 251 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package main
22

33
import (
4-
"strings"
4+
"slices"
55
"testing"
66

77
"github.com/coder/guts/bindings"
@@ -86,12 +86,7 @@ func TestTopoSort(t *testing.T) {
8686
order := topoSort(nodes)
8787

8888
indexOf := func(name string) int {
89-
for i, n := range order {
90-
if n == name {
91-
return i
92-
}
93-
}
94-
return -1
89+
return slices.Index(order, name)
9590
}
9691

9792
if indexOf("C") > indexOf("B") {
@@ -172,250 +167,6 @@ func TestCollectRefsArrayLiteralType(t *testing.T) {
172167
}
173168
}
174169

175-
func TestExprToZodNullableUnion(t *testing.T) {
176-
t.Parallel()
177-
178-
expr := bindings.Union(kw(bindings.KeywordString), &bindings.Null{})
179-
result := exprToZod(expr, "")
180-
181-
if result != "z.string().nullable()" {
182-
t.Errorf("expected z.string().nullable(), got %q", result)
183-
}
184-
}
185-
186-
func TestExprToZodSingleMemberUnion(t *testing.T) {
187-
t.Parallel()
188-
189-
expr := bindings.Union(kw(bindings.KeywordString))
190-
result := exprToZod(expr, "")
191-
192-
if result != "z.string()" {
193-
t.Errorf("expected z.string(), got %q", result)
194-
}
195-
}
196-
197-
func TestExprToZodRecord(t *testing.T) {
198-
t.Parallel()
199-
200-
expr := bindings.Reference(
201-
bindings.Identifier{Name: "Record"},
202-
kw(bindings.KeywordString),
203-
kw(bindings.KeywordString),
204-
)
205-
result := exprToZod(expr, "")
206-
207-
if result != "z.record(z.string(), z.string())" {
208-
t.Errorf("expected z.record(...), got %q", result)
209-
}
210-
}
211-
212-
func TestExprToZodSelfReference(t *testing.T) {
213-
t.Parallel()
214-
215-
expr := ref("Tree")
216-
result := exprToZod(expr, "Tree")
217-
218-
if !strings.Contains(result, "z.lazy") {
219-
t.Errorf("expected z.lazy() for self-reference, got %q", result)
220-
}
221-
}
222-
223-
func TestExprToZodNonSelfReference(t *testing.T) {
224-
t.Parallel()
225-
226-
expr := ref("Other")
227-
result := exprToZod(expr, "Tree")
228-
229-
if result != "OtherSchema" {
230-
t.Errorf("expected OtherSchema, got %q", result)
231-
}
232-
}
233-
234-
func TestSerializeNodeInterface(t *testing.T) {
235-
t.Parallel()
236-
237-
node := &bindings.Interface{
238-
Name: bindings.Identifier{Name: "Foo"},
239-
Fields: []*bindings.PropertySignature{
240-
{Name: "name", Type: kw(bindings.KeywordString)},
241-
},
242-
}
243-
244-
result := serializeNode("Foo", node)
245-
246-
if !strings.Contains(result, "export const FooSchema = z.object(") {
247-
t.Errorf("expected z.object declaration, got:\n%s", result)
248-
}
249-
if !strings.Contains(result, "name: z.string()") {
250-
t.Errorf("expected name field, got:\n%s", result)
251-
}
252-
}
253-
254-
func TestSerializeNodeAlias(t *testing.T) {
255-
t.Parallel()
256-
257-
node := &bindings.Alias{
258-
Name: bindings.Identifier{Name: "MyString"},
259-
Type: kw(bindings.KeywordString),
260-
}
261-
262-
result := serializeNode("MyString", node)
263-
264-
if !strings.Contains(result, "export const MyStringSchema = z.string()") {
265-
t.Errorf("expected z.string() alias, got:\n%s", result)
266-
}
267-
if !strings.Contains(result, "export type MyString = z.infer<typeof MyStringSchema>") {
268-
t.Errorf("expected type export, got:\n%s", result)
269-
}
270-
}
271-
272-
func TestSerializeNodeUnknownReturnsEmpty(t *testing.T) {
273-
t.Parallel()
274-
275-
// LiteralKeyword is a Node but not Interface or Alias.
276-
kword := bindings.KeywordString
277-
result := serializeNode("X", &kword)
278-
279-
if result != "" {
280-
t.Errorf("expected empty string for unknown node type, got %q", result)
281-
}
282-
}
283-
284-
func TestSerializeInterfaceWithHeritage(t *testing.T) {
285-
t.Parallel()
286-
287-
iface := &bindings.Interface{
288-
Name: bindings.Identifier{Name: "Child"},
289-
Heritage: []*bindings.HeritageClause{
290-
bindings.HeritageClauseExtends(ref("Parent")),
291-
},
292-
Fields: []*bindings.PropertySignature{
293-
{Name: "extra", Type: kw(bindings.KeywordString)},
294-
},
295-
}
296-
297-
result := serializeInterface("Child", iface)
298-
299-
if !strings.Contains(result, "ParentSchema.extend") {
300-
t.Errorf("expected ParentSchema.extend, got:\n%s", result)
301-
}
302-
if !strings.Contains(result, "extra: z.string()") {
303-
t.Errorf("expected extra field, got:\n%s", result)
304-
}
305-
}
306-
307-
func TestSerializeStringEnum(t *testing.T) {
308-
t.Parallel()
309-
310-
union := &bindings.UnionType{
311-
Types: []bindings.ExpressionType{
312-
&bindings.LiteralType{Value: "a"},
313-
&bindings.LiteralType{Value: "b"},
314-
},
315-
}
316-
317-
result := serializeStringEnum("Status", union)
318-
319-
if !strings.Contains(result, `z.enum([`) {
320-
t.Errorf("expected z.enum, got:\n%s", result)
321-
}
322-
if !strings.Contains(result, `"a"`) || !strings.Contains(result, `"b"`) {
323-
t.Errorf("expected enum values, got:\n%s", result)
324-
}
325-
}
326-
327-
func TestObjectLiteralToZod(t *testing.T) {
328-
t.Parallel()
329-
330-
tl := &bindings.TypeLiteralNode{
331-
Members: []*bindings.PropertySignature{
332-
{Name: "x", Type: kw(bindings.KeywordNumber)},
333-
{Name: "y", Type: kw(bindings.KeywordString), QuestionToken: true},
334-
},
335-
}
336-
337-
result := objectLiteralToZod(tl, "")
338-
339-
if !strings.Contains(result, "z.object(") {
340-
t.Errorf("expected z.object, got:\n%s", result)
341-
}
342-
if !strings.Contains(result, "x: z.number()") {
343-
t.Errorf("expected x field, got:\n%s", result)
344-
}
345-
if !strings.Contains(result, "y: z.string().optional()") {
346-
t.Errorf("expected optional y field, got:\n%s", result)
347-
}
348-
}
349-
350-
func TestIntersectionToZod(t *testing.T) {
351-
t.Parallel()
352-
353-
t.Run("empty", func(t *testing.T) {
354-
t.Parallel()
355-
inter := &bindings.TypeIntersection{}
356-
if got := intersectionToZod(inter, ""); got != "z.unknown()" {
357-
t.Errorf("expected z.unknown(), got %q", got)
358-
}
359-
})
360-
361-
t.Run("single", func(t *testing.T) {
362-
t.Parallel()
363-
inter := &bindings.TypeIntersection{
364-
Types: []bindings.ExpressionType{kw(bindings.KeywordString)},
365-
}
366-
if got := intersectionToZod(inter, ""); got != "z.string()" {
367-
t.Errorf("expected z.string(), got %q", got)
368-
}
369-
})
370-
371-
t.Run("two types", func(t *testing.T) {
372-
t.Parallel()
373-
inter := &bindings.TypeIntersection{
374-
Types: []bindings.ExpressionType{
375-
ref("A"),
376-
ref("B"),
377-
},
378-
}
379-
got := intersectionToZod(inter, "")
380-
if !strings.Contains(got, "z.intersection(ASchema, BSchema)") {
381-
t.Errorf("expected z.intersection(ASchema, BSchema), got %q", got)
382-
}
383-
})
384-
385-
t.Run("three types nested", func(t *testing.T) {
386-
t.Parallel()
387-
inter := &bindings.TypeIntersection{
388-
Types: []bindings.ExpressionType{
389-
ref("A"),
390-
ref("B"),
391-
ref("C"),
392-
},
393-
}
394-
got := intersectionToZod(inter, "")
395-
expected := "z.intersection(z.intersection(ASchema, BSchema), CSchema)"
396-
if got != expected {
397-
t.Errorf("expected %q, got %q", expected, got)
398-
}
399-
})
400-
}
401-
402-
func TestSerializeNodeSelfReferenceUsesLazy(t *testing.T) {
403-
t.Parallel()
404-
405-
node := &bindings.Interface{
406-
Name: bindings.Identifier{Name: "Tree"},
407-
Fields: []*bindings.PropertySignature{
408-
{Name: "children", Type: bindings.Array(ref("Tree"))},
409-
},
410-
}
411-
412-
result := serializeNode("Tree", node)
413-
414-
if !strings.Contains(result, "z.lazy(") {
415-
t.Errorf("expected z.lazy for self-reference, got:\n%s", result)
416-
}
417-
}
418-
419170
func ref(name string) *bindings.ReferenceType {
420171
return bindings.Reference(bindings.Identifier{Name: name})
421172
}

0 commit comments

Comments
 (0)