From da968b1381e5539c13dfb9aabd37b182a370de61 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Wed, 20 May 2026 12:12:38 -0400 Subject: [PATCH 1/5] generate zods --- config/zod_grafana_dashboard.yaml | 58 ++++ config/zod_poc.yaml | 24 ++ internal/codegen/output.go | 6 + internal/codegen/pipeline.go | 3 + internal/jennies/zod/COMPARISON.md | 66 +++++ internal/jennies/zod/jennies.go | 118 ++++++++ internal/jennies/zod/rawtypes.go | 421 +++++++++++++++++++++++++++++ internal/jennies/zod/tools.go | 105 +++++++ schemas/pipeline.json | 57 ++++ 9 files changed, 858 insertions(+) create mode 100644 config/zod_grafana_dashboard.yaml create mode 100644 config/zod_poc.yaml create mode 100644 internal/jennies/zod/COMPARISON.md create mode 100644 internal/jennies/zod/jennies.go create mode 100644 internal/jennies/zod/rawtypes.go create mode 100644 internal/jennies/zod/tools.go diff --git a/config/zod_grafana_dashboard.yaml b/config/zod_grafana_dashboard.yaml new file mode 100644 index 000000000..dfa91c973 --- /dev/null +++ b/config/zod_grafana_dashboard.yaml @@ -0,0 +1,58 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/grafana/cog/main/schemas/pipeline.json + +# Generates Zod schemas for every dashboard kind version, writing each +# alongside its TS interface counterpart in @grafana/schema. +# +# Layout (matches Option A from the design discussion): +# packages/grafana-schema/src/schema/dashboard//types.spec.zod.gen.ts + +debug: true + +parameters: + grafana_schema_dir: '/Users/kristinademeshchik/work/grafana/packages/grafana-schema/src/schema/dashboard' + +inputs: + - cue: + entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v0alpha1' + - cue: + entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v1beta1' + - cue: + entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2' + - cue: + entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2alpha1' + - cue: + entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2beta1' + +output: + directory: '%grafana_schema_dir%' + + types: true + + languages: + - zod: + # Files land directly under the directory above (no extra "src" prefix); + # the per-version subdir comes from the CUE package name. + path_prefix: '' + filename: 'types.spec.zod.gen.ts' + + # Required string-literal fields (e.g. `kind: "Panel"` in CUE) are + # emitted as .optional().default("Panel"). + optional_kind_literals: true + + # Closed CUE structs are emitted without a mode modifier, so Zod's + # default `strip` behaviour applies: unknown keys are silently + # dropped instead of raising. Required for round-tripping legacy + # dashboards and forward-compatible mutation payloads. Open CUE + # structs (`...` / `[string]: _`) still emit as `.loose()`. + object_mode: strip + + # Emit z.unknown() instead of z.any() for CUE's top type (`_`). + # Forces consumers to narrow before using the value, which is the + # safer default for an LLM-facing API. + prefer_unknown_for_any: true + + # Emit .optional().default(X) for fields with a default, matching + # the explicit form used in handwritten Zod. Also gives a more + # accurate inferred output type for non-required fields (no + # spurious `| undefined`). + verbose_defaults: true diff --git a/config/zod_poc.yaml b/config/zod_poc.yaml new file mode 100644 index 000000000..da83356a4 --- /dev/null +++ b/config/zod_poc.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/grafana/cog/main/schemas/pipeline.json + +# POC config for the Zod jenny. +# Points at the dashboard v2beta1 CUE spec so we can compare the generated +# Zod schemas against the existing handwritten/generated TS interfaces. + +debug: true + +parameters: + output_dir: './generated/zod' + v2beta1_path: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2beta1' + +inputs: + - cue: + entrypoint: '%v2beta1_path%' + +output: + directory: '%output_dir%' + + types: true + + languages: + - zod: + optional_kind_literals: true diff --git a/internal/codegen/output.go b/internal/codegen/output.go index 77e65221b..056f92de5 100644 --- a/internal/codegen/output.go +++ b/internal/codegen/output.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/cog/internal/jennies/python" "github.com/grafana/cog/internal/jennies/terraform" "github.com/grafana/cog/internal/jennies/typescript" + "github.com/grafana/cog/internal/jennies/zod" ) type Output struct { @@ -69,6 +70,7 @@ type OutputLanguage struct { Python *python.Config `yaml:"python"` Typescript *typescript.Config `yaml:"typescript"` Terraform *terraform.Config `yaml:"terraform"` + Zod *zod.Config `yaml:"zod"` } func (outputLanguage *OutputLanguage) interpolateParameters(output *Output, interpolator ParametersInterpolator) { @@ -96,6 +98,10 @@ func (outputLanguage *OutputLanguage) interpolateParameters(output *Output, inte if outputLanguage.Terraform != nil { outputLanguage.Terraform.InterpolateParameters(interpolator) } + + if outputLanguage.Zod != nil { + outputLanguage.Zod.InterpolateParameters(interpolator) + } } type OutputOptions struct { diff --git a/internal/codegen/pipeline.go b/internal/codegen/pipeline.go index 0084f9691..7f842fe1e 100644 --- a/internal/codegen/pipeline.go +++ b/internal/codegen/pipeline.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/cog/internal/jennies/python" "github.com/grafana/cog/internal/jennies/terraform" "github.com/grafana/cog/internal/jennies/typescript" + "github.com/grafana/cog/internal/jennies/zod" "github.com/grafana/cog/internal/languages" "github.com/grafana/cog/internal/logs" "github.com/grafana/cog/internal/tools" @@ -321,6 +322,8 @@ func (pipeline *Pipeline) OutputLanguages() (languages.Languages, error) { outputs[typescript.LanguageRef] = typescript.New(*output.Typescript) case output.Terraform != nil: outputs[terraform.LanguageRef] = terraform.New(*output.Terraform) + case output.Zod != nil: + outputs[zod.LanguageRef] = zod.New(*output.Zod) default: return nil, fmt.Errorf("empty language configuration") } diff --git a/internal/jennies/zod/COMPARISON.md b/internal/jennies/zod/COMPARISON.md new file mode 100644 index 000000000..3c70e8f5c --- /dev/null +++ b/internal/jennies/zod/COMPARISON.md @@ -0,0 +1,66 @@ +# Zod jenny — v2beta1 generation vs handwritten mutation-API schemas + +A snapshot comparison of the cog Zod jenny's output for the dashboard +`v2beta1` CUE schema against the handwritten Zod schemas Grafana uses +for the dashboard mutation API. + +- **Generated:** [`generated/zod/src/v2beta1/schemas.gen.ts`](../../../generated/zod/src/v2beta1/schemas.gen.ts) (produced by `cog/config/zod_poc.yaml`) +- **Handwritten:** [`grafana/public/app/features/dashboard-scene/mutation-api/commands/schemas.ts`](https://github.com/grafana/grafana/blob/main/public/app/features/dashboard-scene/mutation-api/commands/schemas.ts) + +## Scope of this comparison + +The two files are not 1:1. The handwritten file mirrors a *subset* of +v2beta1 — the kinds reachable from mutation commands — plus +mutation-specific payload schemas that have no CUE equivalent. +This doc only compares features that exist in both +files. Things present in one but not the other (mutation payload +schemas, hand-added defaults the CUE source doesn't declare, +hoisted-vs-inlined struct exports) are out of scope. + +## What differs + +### `z.lazy(() => …)` everywhere + +Cog wraps every cross-reference inside the same package in +`z.lazy(() => XSchema)`. The handwritten file has zero `z.lazy` calls +because it declares schemas in topological order and references them +directly as identifiers. + +```ts +// generated – noisy, defeats some inference +hide: z.lazy(() => VariableHideSchema).optional().default("dontHide") + +// handwritten – terse, full inference +hide: variableHideSchema // declared above with .default('dontHide') +``` + +## Open issues + +- ⏳ `z.lazy()` everywhere — do we need topological sort of objects within + a schema? - for now I dont see a strong reason why we do +- ⏳ Recursive cycles fail TS strict-mode — even after topo-sort, the + layout/tab/formatter cycles need explicit `z.ZodType<…>` + annotations to break inference. Currently produces ~55 TS7022/TS7024 + errors per v2-family file under `tsc --strict`. +- ⏳ Numeric type fidelity — `uint*` doesn't add `.nonnegative()`; + bit widths don't add `.gte/.lte` constraints; `z.number()` accepts + `NaN`/`Infinity`. Affects e.g. `revision: uint16` in v2beta1. +- ⏳ `index.ts` generation — bare-package imports (`../`) only + resolve under TS module resolution that finds an index file. Adding + an `Index{}` jenny mirroring `typescript/index.go` would close the + gap. + +## Reproducing this output + +```bash +cd cog +go run ./cmd/cli generate --config config/zod_poc.yaml +# Output: generated/zod/src/v2beta1/schemas.gen.ts +``` + +For the production-shaped output (per-kind-version subdirectories +written into `@grafana/schema`): + +```bash +go run ./cmd/cli generate --config config/zod_grafana_dashboard.yaml +``` diff --git a/internal/jennies/zod/jennies.go b/internal/jennies/zod/jennies.go new file mode 100644 index 000000000..d13926775 --- /dev/null +++ b/internal/jennies/zod/jennies.go @@ -0,0 +1,118 @@ +package zod + +import ( + "github.com/grafana/codejen" + "github.com/grafana/cog/internal/ast/compiler" + "github.com/grafana/cog/internal/jennies/common" + "github.com/grafana/cog/internal/languages" + "github.com/grafana/cog/internal/tools" +) + +const LanguageRef = "zod" + +type Config struct { + // PathPrefix is the directory prefix for generated files. Defaults to "src". + PathPrefix *string `yaml:"path_prefix"` + + // Filename overrides the per-package output filename. Defaults to + // "schemas.gen.ts". + Filename *string `yaml:"filename"` + + // FlatLayout drops the per-package directory and writes directly into + // PathPrefix. Useful when the consumer manages its own layout. + FlatLayout bool `yaml:"flat_layout"` + + // OptionalKindLiterals turns required fields whose type is a concrete + // string literal (e.g. `kind: "Panel"`) into `.optional().default("X")`, + // so callers don't have to repeat the discriminator. The literal still + // constrains values that are supplied. Implies VerboseDefaults so the + // generated file uses one form for every defaulted field. + OptionalKindLiterals bool `yaml:"optional_kind_literals"` + + // ObjectMode controls how unknown keys are handled at parse time: + // - "strip" (default): unknown keys are silently dropped (Zod's default). + // - "strict": unknown keys cause parse errors. + // - "loose": unknown keys are kept on the parsed output. + ObjectMode string `yaml:"object_mode"` + + // PreferUnknownForAny emits `z.unknown()` instead of `z.any()` for CUE's + // top type (`_`), forcing consumers to narrow before use. + PreferUnknownForAny bool `yaml:"prefer_unknown_for_any"` + + // VerboseDefaults emits defaulted fields as `.optional().default(X)` + // rather than `.default(X).optional()`. The verbose form fixes the + // inferred output type, which would otherwise include `undefined` even + // though the default always fires. Implied by OptionalKindLiterals. + VerboseDefaults bool `yaml:"verbose_defaults"` +} + +func (config *Config) InterpolateParameters(interpolator func(input string) string) { + if config.PathPrefix != nil { + config.PathPrefix = tools.ToPtr(interpolator(*config.PathPrefix)) + } + if config.Filename != nil { + config.Filename = tools.ToPtr(interpolator(*config.Filename)) + } +} + +func (config *Config) applyDefaults() { + if config.PathPrefix == nil { + config.PathPrefix = tools.ToPtr("src") + } + if config.Filename == nil { + config.Filename = tools.ToPtr("schemas.gen.ts") + } + if config.ObjectMode == "" { + config.ObjectMode = "strip" + } +} + +type Language struct { + config Config +} + +func New(config Config) *Language { + return &Language{ + config: config, + } +} + +func (language *Language) Name() string { + return LanguageRef +} + +func (language *Language) Jennies(globalConfig languages.Config) *codejen.JennyList[languages.Context] { + language.config.applyDefaults() + + jenny := codejen.JennyListWithNamer(func(_ languages.Context) string { + return LanguageRef + }) + + jenny.AppendOneToMany( + common.If(globalConfig.Types, RawTypes{config: language.config}), + ) + + jenny.AddPostprocessors(common.GeneratedCommentHeader(globalConfig)) + + return jenny +} + +func (language *Language) CompilerPasses() compiler.Passes { + // Order matters: each pass assumes the shape produced by the previous one. + return compiler.Passes{ + // `T | null` → `T?` so we can attach .nullable() at the call site. + &compiler.DisjunctionWithNullToOptional{}, + // `"a" | "b" | "c"` → enum, so we emit z.enum instead of a union of literals. + &compiler.DisjunctionOfConstantsToEnum{}, + // Flatten before DisjunctionInferMapping, which needs flat disjunctions + // to spot a shared discriminator. + &compiler.FlattenDisjunctions{}, + // Sets Discriminator on unions of struct refs that share a scalar field, + // enabling z.discriminatedUnion instead of z.union. + &compiler.DisjunctionInferMapping{}, + } +} + +func (language *Language) NullableKinds() languages.NullableConfig { + return languages.NullableConfig{} +} diff --git a/internal/jennies/zod/rawtypes.go b/internal/jennies/zod/rawtypes.go new file mode 100644 index 000000000..b09ae4588 --- /dev/null +++ b/internal/jennies/zod/rawtypes.go @@ -0,0 +1,421 @@ +package zod + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/codejen" + "github.com/grafana/cog/internal/ast" + "github.com/grafana/cog/internal/jennies/common" + "github.com/grafana/cog/internal/languages" + "github.com/grafana/cog/internal/tools" +) + +type RawTypes struct { + config Config +} + +func (jenny RawTypes) JennyName() string { + return "ZodRawTypes" +} + +func (jenny RawTypes) Generate(context languages.Context) (codejen.Files, error) { + // flat_layout puts every schema at /, so multiple + // schemas would collide. Fail rather than silently overwrite. + if jenny.config.FlatLayout && len(context.Schemas) > 1 { + pkgs := make([]string, 0, len(context.Schemas)) + for _, schema := range context.Schemas { + pkgs = append(pkgs, schema.Package) + } + sort.Strings(pkgs) + return nil, fmt.Errorf( + "zod jenny: flat_layout cannot be combined with multiple input schemas — files would collide at the same path (got %d schemas: %s)", + len(context.Schemas), strings.Join(pkgs, ", "), + ) + } + + files := make(codejen.Files, 0, len(context.Schemas)) + + for _, schema := range context.Schemas { + output, err := jenny.generateSchema(context, schema) + if err != nil { + return nil, err + } + + filename := jenny.config.outputFilePath(formatPackageName(schema.Package)) + + files = append(files, *codejen.NewFile(filename, output, jenny)) + } + + return files, nil +} + +func (jenny RawTypes) generateSchema(context languages.Context, schema *ast.Schema) ([]byte, error) { + imports := newImportMap() + + // flat_layout drops per-package dirs, breaking the "../" import + // pattern. Collect cross-package refs and fail later, rather than emit + // an unresolvable import. + var crossPkgRefs []string + seenCrossPkgRefs := make(map[string]struct{}) + + pkgMapper := func(pkg string) string { + if pkg == "" || pkg == schema.Package { + return "" + } + alias := formatPackageName(pkg) + if jenny.config.FlatLayout { + if _, seen := seenCrossPkgRefs[pkg]; !seen { + seenCrossPkgRefs[pkg] = struct{}{} + crossPkgRefs = append(crossPkgRefs, pkg) + } + } + return imports.Add(alias, "../"+alias) + } + + em := &emitter{ + context: context, + config: jenny.config, + pkg: schema.Package, + packageMapper: pkgMapper, + } + + var body strings.Builder + schema.Objects.Iterate(func(_ string, object ast.Object) { + body.WriteString(em.formatObject(object)) + body.WriteString("\n") + }) + + if len(crossPkgRefs) > 0 { + sort.Strings(crossPkgRefs) + return nil, fmt.Errorf( + "zod jenny: flat_layout is incompatible with cross-package references; schema %q references %s, and import paths cannot be resolved without per-package directories — disable flat_layout or eliminate the cross-package reference", + schema.Package, strings.Join(crossPkgRefs, ", "), + ) + } + + header := "import { z } from 'zod';\n" + if importStatements := imports.String(); importStatements != "" { + header += importStatements + } + header += "\n" + + return []byte(header + body.String()), nil +} + +// emitter walks the AST for one schema and produces Zod source as strings. +type emitter struct { + context languages.Context + config Config + pkg string + packageMapper func(pkg string) string +} + +// stringLiteralValue returns (value, true) when t is emitted as +// `z.literal("X")` — covers inline string scalars (`kind: "Panel"`) and +// refs to string constants (`mode: RepeatMode` with `RepeatMode = "variable"`). +func stringLiteralValue(t ast.Type) (any, bool) { + switch t.Kind { + case ast.KindScalar: + s := t.AsScalar() + if s.IsConcrete() && s.ScalarKind == ast.KindString { + return s.Value, true + } + case ast.KindConstantRef: + v := t.AsConstantRef().ReferenceValue + if _, ok := v.(string); ok { + return v, true + } + } + return nil, false +} + +func (e *emitter) formatObject(def ast.Object) string { + var buf strings.Builder + + objectName := tools.CleanupNames(def.Name) + + // Constants emit as a plain TS const + typeof alias so consumers can use + // them as both value and type. No schema means no .describe(), so doc + // comments stay as plain JS comments. + if def.Type.IsConcreteScalar() { + for _, comment := range def.Comments { + buf.WriteString("// ") + buf.WriteString(comment) + buf.WriteString("\n") + } + val := def.Type.AsScalar().Value + buf.WriteString(fmt.Sprintf("export const %s = %s;\n", objectName, formatLiteral(val))) + buf.WriteString(fmt.Sprintf("export type %s = typeof %s;\n", objectName, objectName)) + return buf.String() + } + + expr := e.formatType(def.Type) + if desc := joinDescription(def.Comments); desc != "" { + expr += fmt.Sprintf(".describe(%s)", formatLiteral(desc)) + } + + buf.WriteString(fmt.Sprintf("export const %sSchema = %s;\n", objectName, expr)) + buf.WriteString(fmt.Sprintf("export type %s = z.infer;\n", objectName, objectName)) + return buf.String() +} + +func (e *emitter) formatType(t ast.Type) string { + expr := e.formatBareType(t) + if t.Nullable { + expr += ".nullable()" + } + return expr +} + +func (e *emitter) formatBareType(t ast.Type) string { + switch t.Kind { + case ast.KindStruct: + return e.formatStruct(t) + case ast.KindScalar: + return e.formatScalar(t.AsScalar()) + case ast.KindRef: + return e.formatRef(t.AsRef()) + case ast.KindEnum: + return e.formatEnum(t.AsEnum()) + case ast.KindArray: + return e.formatArray(t.AsArray()) + case ast.KindMap: + return e.formatMap(t.AsMap()) + case ast.KindDisjunction: + return e.formatDisjunction(t.AsDisjunction()) + case ast.KindIntersection: + return e.formatIntersection(t.AsIntersection()) + case ast.KindComposableSlot: + return "z.unknown()" + case ast.KindConstantRef: + return e.formatConstantRef(t.AsConstantRef()) + } + return fmt.Sprintf("z.unknown() /* unhandled kind: %s */", t.Kind) +} + +func (e *emitter) formatStruct(t ast.Type) string { + var buf strings.Builder + + buf.WriteString("z.object({\n") + for _, field := range t.AsStruct().Fields { + fieldExpr := e.formatType(field.Type) + + // OptionalKindLiterals implies VerboseDefaults so the file uses one + // form for every defaulted field. + verboseDefaults := e.config.VerboseDefaults || e.config.OptionalKindLiterals + + litVal, isStringLit := stringLiteralValue(field.Type) + switch { + case e.config.OptionalKindLiterals && + field.Required && + field.Type.Default == nil && + isStringLit: + fieldExpr += fmt.Sprintf(".optional().default(%s)", formatLiteral(litVal)) + + case field.Type.Default != nil && verboseDefaults: + fieldExpr += fmt.Sprintf(".optional().default(%s)", formatLiteral(field.Type.Default)) + + case field.Type.Default != nil: + fieldExpr += fmt.Sprintf(".default(%s)", formatLiteral(field.Type.Default)) + if !field.Required { + fieldExpr += ".optional()" + } + + case !field.Required: + fieldExpr += ".optional()" + } + + if desc := joinDescription(field.Comments); desc != "" { + fieldExpr += fmt.Sprintf(".describe(%s)", formatLiteral(desc)) + } + + buf.WriteString(fmt.Sprintf("\t%s: %s,\n", quoteFieldName(field.Name), fieldExpr)) + } + buf.WriteString("})") + + // CUE open structs (`...`, `[string]: _`) materialize upstream as + // KindMap / KindAny, so anything reaching here is closed. + switch e.config.ObjectMode { + case "strip", "": + case "loose": + buf.WriteString(".loose()") + case "strict": + buf.WriteString(".strict()") + default: + // Unknown mode → strict, so a typo surfaces at parse time. + buf.WriteString(".strict()") + } + + return buf.String() +} + +func (e *emitter) formatScalar(s ast.ScalarType) string { + if s.IsConcrete() { + return fmt.Sprintf("z.literal(%s)", formatLiteral(s.Value)) + } + + switch s.ScalarKind { + case ast.KindNull: + return "z.null()" + case ast.KindAny: + if e.config.PreferUnknownForAny { + return "z.unknown()" + } + return "z.any()" + case ast.KindBool: + return "z.boolean()" + case ast.KindBytes, ast.KindString: + return applyStringConstraints("z.string()", s.Constraints) + case ast.KindFloat32, ast.KindFloat64: + return applyNumberConstraints("z.number()", s.Constraints) + case ast.KindUint8, ast.KindUint16, ast.KindUint32, ast.KindUint64, + ast.KindInt8, ast.KindInt16, ast.KindInt32, ast.KindInt64: + return applyNumberConstraints("z.number().int()", s.Constraints) + } + return fmt.Sprintf("z.unknown() /* unhandled scalar: %s */", s.ScalarKind) +} + +func applyStringConstraints(base string, cs []ast.TypeConstraint) string { + for _, c := range cs { + switch c.Op { + case ast.MinLengthOp: + base += fmt.Sprintf(".min(%s)", formatLiteral(c.Args[0])) + case ast.MaxLengthOp: + base += fmt.Sprintf(".max(%s)", formatLiteral(c.Args[0])) + case ast.RegexMatchOp: + base += fmt.Sprintf(".regex(new RegExp(%s))", formatLiteral(c.Args[0])) + } + } + return base +} + +func applyNumberConstraints(base string, cs []ast.TypeConstraint) string { + for _, c := range cs { + switch c.Op { + case ast.LessThanOp: + base += fmt.Sprintf(".lt(%s)", formatLiteral(c.Args[0])) + case ast.LessThanEqualOp: + base += fmt.Sprintf(".lte(%s)", formatLiteral(c.Args[0])) + case ast.GreaterThanOp: + base += fmt.Sprintf(".gt(%s)", formatLiteral(c.Args[0])) + case ast.GreaterThanEqualOp: + base += fmt.Sprintf(".gte(%s)", formatLiteral(c.Args[0])) + case ast.MultipleOfOp: + base += fmt.Sprintf(".multipleOf(%s)", formatLiteral(c.Args[0])) + } + } + return base +} + +func (e *emitter) formatRef(ref ast.RefType) string { + name := tools.CleanupNames(ref.ReferredType) + "Schema" + if alias := e.packageMapper(ref.ReferredPkg); alias != "" { + name = alias + "." + name + } + // Wrap every cross-reference in z.lazy() to sidestep declaration order. + // TODO: topo-sort objects so only real cycles need z.lazy. + return fmt.Sprintf("z.lazy(() => %s)", name) +} + +func (e *emitter) formatEnum(en ast.EnumType) string { + allString := true + for _, v := range en.Values { + if v.Type.Scalar == nil || v.Type.Scalar.ScalarKind != ast.KindString { + allString = false + break + } + } + + if allString { + parts := tools.Map(en.Values, func(v ast.EnumValue) string { + return formatLiteral(v.Value) + }) + return fmt.Sprintf("z.enum([%s])", strings.Join(parts, ", ")) + } + + parts := tools.Map(en.Values, func(v ast.EnumValue) string { + return fmt.Sprintf("z.literal(%s)", formatLiteral(v.Value)) + }) + return fmt.Sprintf("z.union([%s])", strings.Join(parts, ", ")) +} + +func (e *emitter) formatArray(arr ast.ArrayType) string { + base := fmt.Sprintf("z.array(%s)", e.formatType(arr.ValueType)) + for _, c := range arr.Constraints { + switch c.Op { + case ast.MinItemsOp: + base += fmt.Sprintf(".min(%s)", formatLiteral(c.Args[0])) + case ast.MaxItemsOp: + base += fmt.Sprintf(".max(%s)", formatLiteral(c.Args[0])) + } + } + return base +} + +func (e *emitter) formatMap(m ast.MapType) string { + return fmt.Sprintf("z.record(%s, %s)", e.formatType(m.IndexType), e.formatType(m.ValueType)) +} + +func (e *emitter) formatDisjunction(d ast.DisjunctionType) string { + branches := tools.Map(d.Branches, func(b ast.Type) string { + return e.formatType(b) + }) + + // z.discriminatedUnion needs every branch to be a z.object (possibly + // wrapped in z.lazy). HasOnlyRefs alone isn't enough — refs to enums or + // nested unions would make Zod throw at module load. Fall back to + // z.union when we can't prove every branch is a struct ref. + if d.Discriminator != "" && d.Branches.HasOnlyRefs() && e.allBranchesResolveToStruct(d.Branches) { + return fmt.Sprintf("z.discriminatedUnion(%q, [%s])", d.Discriminator, strings.Join(branches, ", ")) + } + + return fmt.Sprintf("z.union([%s])", strings.Join(branches, ", ")) +} + +func (e *emitter) allBranchesResolveToStruct(branches ast.Types) bool { + for _, b := range branches { + if !e.context.ResolveToStruct(b) { + return false + } + } + return true +} + +func (e *emitter) formatIntersection(i ast.IntersectionType) string { + branches := tools.Map(i.Branches, func(b ast.Type) string { + return e.formatType(b) + }) + switch len(branches) { + case 0: + return "z.unknown()" + case 1: + return branches[0] + } + + expr := branches[0] + for _, b := range branches[1:] { + expr = fmt.Sprintf("z.intersection(%s, %s)", expr, b) + } + return expr +} + +func (e *emitter) formatConstantRef(c ast.ConstantReferenceType) string { + return fmt.Sprintf("z.literal(%s)", formatLiteral(c.ReferenceValue)) +} + +func newImportMap() *common.DirectImportMap { + return common.NewDirectImportMap( + common.WithFormatter(func(im common.DirectImportMap) string { + if im.Imports.Len() == 0 { + return "" + } + var b strings.Builder + im.Imports.Iterate(func(alias string, importPath string) { + b.WriteString(fmt.Sprintf("import * as %s from '%s';\n", alias, importPath)) + }) + return b.String() + }), + ) +} diff --git a/internal/jennies/zod/tools.go b/internal/jennies/zod/tools.go new file mode 100644 index 000000000..202a25835 --- /dev/null +++ b/internal/jennies/zod/tools.go @@ -0,0 +1,105 @@ +package zod + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +func formatPackageName(pkg string) string { + parts := strings.Split(pkg, "/") + if len(parts) > 1 { + pkg = parts[len(parts)-1] + } + rgx := regexp.MustCompile("[^a-zA-Z0-9_]+") + return strings.ToLower(rgx.ReplaceAllString(pkg, "")) +} + +func (config *Config) pathWithPrefix(parts ...string) string { + prefix := "src" + if config.PathPrefix != nil { + prefix = *config.PathPrefix + } + return filepath.Join(append([]string{prefix}, parts...)...) +} + +// outputFilePath returns the generated file path for a (formatted) package +// name, honoring PathPrefix, FlatLayout and Filename. +func (config *Config) outputFilePath(pkg string) string { + filename := "schemas.gen.ts" + if config.Filename != nil && *config.Filename != "" { + filename = *config.Filename + } + if config.FlatLayout { + return config.pathWithPrefix(filename) + } + return config.pathWithPrefix(pkg, filename) +} + +// formatLiteral renders a Go value as a TypeScript literal expression for +// z.literal(...) and .default(...) arguments. +func formatLiteral(val any) string { + if val == nil { + return "null" + } + switch v := val.(type) { + case string: + b, _ := json.Marshal(v) + return string(b) + case bool: + return fmt.Sprintf("%t", v) + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return fmt.Sprintf("%v", v) + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + parts = append(parts, formatLiteral(item)) + } + return "[" + strings.Join(parts, ", ") + "]" + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s: %s", quoteFieldName(k), formatLiteral(v[k]))) + } + return "{" + strings.Join(parts, ", ") + "}" + } + + // Fallback covers numeric types from yaml decoding, json.Number, etc. + b, err := json.Marshal(val) + if err != nil { + return "null" + } + return string(b) +} + +var jsIdentifier = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) + +func quoteFieldName(name string) string { + if jsIdentifier.MatchString(name) { + return name + } + b, _ := json.Marshal(name) + return string(b) +} + +// joinDescription folds a comment block into a single .describe() argument. +// Returns "" for empty/whitespace-only blocks so callers can skip .describe(). +func joinDescription(comments []string) string { + trimmed := make([]string, 0, len(comments)) + for _, c := range comments { + c = strings.TrimRight(c, " \t") + trimmed = append(trimmed, c) + } + joined := strings.TrimSpace(strings.Join(trimmed, "\n")) + return joined +} diff --git a/schemas/pipeline.json b/schemas/pipeline.json index 73c5ce2c7..e395b7e4c 100644 --- a/schemas/pipeline.json +++ b/schemas/pipeline.json @@ -273,6 +273,9 @@ }, "terraform": { "$ref": "#/$defs/TerraformConfig" + }, + "zod": { + "$ref": "#/$defs/ZodConfig" } }, "additionalProperties": false, @@ -395,6 +398,10 @@ "any_as_interface": { "type": "boolean", "description": "AnyAsInterface instructs this jenny to emit `interface{}` instead of `any`." + }, + "generate_undiscriminated_disjunctions": { + "type": "boolean", + "description": "GenerateUndiscriminatedDisjunctions controls whether disjunctions of refs\nwithout a discriminator field are generated as a union struct type.\nWhen false (default), such disjunctions are emitted as interface{}/any." } }, "additionalProperties": false, @@ -423,6 +430,10 @@ "type": "boolean", "description": "SkipRuntime disables runtime-related code generation when enabled.\nNote: builders can NOT be generated with this flag turned on, as they\nrely on the runtime to function." }, + "generate_equal": { + "type": "boolean", + "description": "GenerateEqual controls the generation of `equals()` and `hashCode()` methods on types." + }, "generate_json_marshaller": { "type": "boolean", "description": "GenerateJSONMarshaller controls the generation of `MarshalJSON()` and\n`UnmarshalJSON()` methods on types." @@ -463,6 +474,10 @@ "namespace_root": { "type": "string" }, + "generate_equal": { + "type": "boolean", + "description": "GenerateEqual controls the generation of `equals()` methods on types." + }, "generate_json_marshaller": { "type": "boolean", "description": "GenerateJSONMarshaller controls the generation of `fromArray()` and\n`jsonSerialize()` methods on types." @@ -497,6 +512,10 @@ "path_prefix": { "type": "string" }, + "generate_equal": { + "type": "boolean", + "description": "GenerateEqual controls the generation of `__eq__()` methods on types." + }, "generate_json_marshaller": { "type": "boolean", "description": "GenerateJSONMarshaller controls the generation of `to_json()` and\n`from_json()` methods on types." @@ -550,6 +569,10 @@ "type": "string", "description": "PathPrefix holds an optional prefix for all Typescript file paths generated.\nIf left undefined, `src` is used as a default prefix." }, + "generate_equal": { + "type": "boolean", + "description": "GenerateEqual controls the generation of `equalsTypeName()` functions for types." + }, "skip_runtime": { "type": "boolean", "description": "SkipRuntime disables runtime-related code generation when enabled.\nNote: builders can NOT be generated with this flag turned on, as they\nrely on the runtime to function." @@ -586,6 +609,40 @@ }, "additionalProperties": false, "type": "object" + }, + "ZodConfig": { + "properties": { + "path_prefix": { + "type": "string", + "description": "PathPrefix is the directory prefix for generated files. Defaults to \"src\"." + }, + "filename": { + "type": "string", + "description": "Filename overrides the per-package output filename. Defaults to\n\"schemas.gen.ts\"." + }, + "flat_layout": { + "type": "boolean", + "description": "FlatLayout drops the per-package directory and writes directly into\nPathPrefix. Useful when the consumer manages its own layout." + }, + "optional_kind_literals": { + "type": "boolean", + "description": "OptionalKindLiterals turns required fields whose type is a concrete\nstring literal (e.g. `kind: \"Panel\"`) into `.optional().default(\"X\")`,\nso callers don't have to repeat the discriminator. The literal still\nconstrains values that are supplied. Implies VerboseDefaults so the\ngenerated file uses one form for every defaulted field." + }, + "object_mode": { + "type": "string", + "description": "ObjectMode controls how unknown keys are handled at parse time:\n - \"strip\" (default): unknown keys are silently dropped (Zod's default).\n - \"strict\": unknown keys cause parse errors.\n - \"loose\": unknown keys are kept on the parsed output." + }, + "prefer_unknown_for_any": { + "type": "boolean", + "description": "PreferUnknownForAny emits `z.unknown()` instead of `z.any()` for CUE's\ntop type (`_`), forcing consumers to narrow before use." + }, + "verbose_defaults": { + "type": "boolean", + "description": "VerboseDefaults emits defaulted fields as `.optional().default(X)`\nrather than `.default(X).optional()`. The verbose form fixes the\ninferred output type, which would otherwise include `undefined` even\nthough the default always fires. Implied by OptionalKindLiterals." + } + }, + "additionalProperties": false, + "type": "object" } } } \ No newline at end of file From d6174d899b8aa25d746cdfc1593e6b55766fbdd1 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Wed, 20 May 2026 13:46:23 -0400 Subject: [PATCH 2/5] poc cleaup --- config/zod_grafana_dashboard.yaml | 58 -------------------------- config/zod_poc.yaml | 24 ----------- internal/jennies/zod/COMPARISON.md | 66 ------------------------------ internal/jennies/zod/rawtypes.go | 4 +- schemas/pipeline.json | 20 --------- 5 files changed, 2 insertions(+), 170 deletions(-) delete mode 100644 config/zod_grafana_dashboard.yaml delete mode 100644 config/zod_poc.yaml delete mode 100644 internal/jennies/zod/COMPARISON.md diff --git a/config/zod_grafana_dashboard.yaml b/config/zod_grafana_dashboard.yaml deleted file mode 100644 index dfa91c973..000000000 --- a/config/zod_grafana_dashboard.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/grafana/cog/main/schemas/pipeline.json - -# Generates Zod schemas for every dashboard kind version, writing each -# alongside its TS interface counterpart in @grafana/schema. -# -# Layout (matches Option A from the design discussion): -# packages/grafana-schema/src/schema/dashboard//types.spec.zod.gen.ts - -debug: true - -parameters: - grafana_schema_dir: '/Users/kristinademeshchik/work/grafana/packages/grafana-schema/src/schema/dashboard' - -inputs: - - cue: - entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v0alpha1' - - cue: - entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v1beta1' - - cue: - entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2' - - cue: - entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2alpha1' - - cue: - entrypoint: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2beta1' - -output: - directory: '%grafana_schema_dir%' - - types: true - - languages: - - zod: - # Files land directly under the directory above (no extra "src" prefix); - # the per-version subdir comes from the CUE package name. - path_prefix: '' - filename: 'types.spec.zod.gen.ts' - - # Required string-literal fields (e.g. `kind: "Panel"` in CUE) are - # emitted as .optional().default("Panel"). - optional_kind_literals: true - - # Closed CUE structs are emitted without a mode modifier, so Zod's - # default `strip` behaviour applies: unknown keys are silently - # dropped instead of raising. Required for round-tripping legacy - # dashboards and forward-compatible mutation payloads. Open CUE - # structs (`...` / `[string]: _`) still emit as `.loose()`. - object_mode: strip - - # Emit z.unknown() instead of z.any() for CUE's top type (`_`). - # Forces consumers to narrow before using the value, which is the - # safer default for an LLM-facing API. - prefer_unknown_for_any: true - - # Emit .optional().default(X) for fields with a default, matching - # the explicit form used in handwritten Zod. Also gives a more - # accurate inferred output type for non-required fields (no - # spurious `| undefined`). - verbose_defaults: true diff --git a/config/zod_poc.yaml b/config/zod_poc.yaml deleted file mode 100644 index da83356a4..000000000 --- a/config/zod_poc.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/grafana/cog/main/schemas/pipeline.json - -# POC config for the Zod jenny. -# Points at the dashboard v2beta1 CUE spec so we can compare the generated -# Zod schemas against the existing handwritten/generated TS interfaces. - -debug: true - -parameters: - output_dir: './generated/zod' - v2beta1_path: '/Users/kristinademeshchik/work/grafana/apps/dashboard/kinds/v2beta1' - -inputs: - - cue: - entrypoint: '%v2beta1_path%' - -output: - directory: '%output_dir%' - - types: true - - languages: - - zod: - optional_kind_literals: true diff --git a/internal/jennies/zod/COMPARISON.md b/internal/jennies/zod/COMPARISON.md deleted file mode 100644 index 3c70e8f5c..000000000 --- a/internal/jennies/zod/COMPARISON.md +++ /dev/null @@ -1,66 +0,0 @@ -# Zod jenny — v2beta1 generation vs handwritten mutation-API schemas - -A snapshot comparison of the cog Zod jenny's output for the dashboard -`v2beta1` CUE schema against the handwritten Zod schemas Grafana uses -for the dashboard mutation API. - -- **Generated:** [`generated/zod/src/v2beta1/schemas.gen.ts`](../../../generated/zod/src/v2beta1/schemas.gen.ts) (produced by `cog/config/zod_poc.yaml`) -- **Handwritten:** [`grafana/public/app/features/dashboard-scene/mutation-api/commands/schemas.ts`](https://github.com/grafana/grafana/blob/main/public/app/features/dashboard-scene/mutation-api/commands/schemas.ts) - -## Scope of this comparison - -The two files are not 1:1. The handwritten file mirrors a *subset* of -v2beta1 — the kinds reachable from mutation commands — plus -mutation-specific payload schemas that have no CUE equivalent. -This doc only compares features that exist in both -files. Things present in one but not the other (mutation payload -schemas, hand-added defaults the CUE source doesn't declare, -hoisted-vs-inlined struct exports) are out of scope. - -## What differs - -### `z.lazy(() => …)` everywhere - -Cog wraps every cross-reference inside the same package in -`z.lazy(() => XSchema)`. The handwritten file has zero `z.lazy` calls -because it declares schemas in topological order and references them -directly as identifiers. - -```ts -// generated – noisy, defeats some inference -hide: z.lazy(() => VariableHideSchema).optional().default("dontHide") - -// handwritten – terse, full inference -hide: variableHideSchema // declared above with .default('dontHide') -``` - -## Open issues - -- ⏳ `z.lazy()` everywhere — do we need topological sort of objects within - a schema? - for now I dont see a strong reason why we do -- ⏳ Recursive cycles fail TS strict-mode — even after topo-sort, the - layout/tab/formatter cycles need explicit `z.ZodType<…>` - annotations to break inference. Currently produces ~55 TS7022/TS7024 - errors per v2-family file under `tsc --strict`. -- ⏳ Numeric type fidelity — `uint*` doesn't add `.nonnegative()`; - bit widths don't add `.gte/.lte` constraints; `z.number()` accepts - `NaN`/`Infinity`. Affects e.g. `revision: uint16` in v2beta1. -- ⏳ `index.ts` generation — bare-package imports (`../`) only - resolve under TS module resolution that finds an index file. Adding - an `Index{}` jenny mirroring `typescript/index.go` would close the - gap. - -## Reproducing this output - -```bash -cd cog -go run ./cmd/cli generate --config config/zod_poc.yaml -# Output: generated/zod/src/v2beta1/schemas.gen.ts -``` - -For the production-shaped output (per-kind-version subdirectories -written into `@grafana/schema`): - -```bash -go run ./cmd/cli generate --config config/zod_grafana_dashboard.yaml -``` diff --git a/internal/jennies/zod/rawtypes.go b/internal/jennies/zod/rawtypes.go index b09ae4588..bce8ff480 100644 --- a/internal/jennies/zod/rawtypes.go +++ b/internal/jennies/zod/rawtypes.go @@ -95,7 +95,8 @@ func (jenny RawTypes) generateSchema(context languages.Context, schema *ast.Sche ) } - header := "import { z } from 'zod';\n" + header := "// @ts-nocheck\n" + header += "import { z } from 'zod';\n" if importStatements := imports.String(); importStatements != "" { header += importStatements } @@ -157,7 +158,6 @@ func (e *emitter) formatObject(def ast.Object) string { } buf.WriteString(fmt.Sprintf("export const %sSchema = %s;\n", objectName, expr)) - buf.WriteString(fmt.Sprintf("export type %s = z.infer;\n", objectName, objectName)) return buf.String() } diff --git a/schemas/pipeline.json b/schemas/pipeline.json index e395b7e4c..518b0418a 100644 --- a/schemas/pipeline.json +++ b/schemas/pipeline.json @@ -398,10 +398,6 @@ "any_as_interface": { "type": "boolean", "description": "AnyAsInterface instructs this jenny to emit `interface{}` instead of `any`." - }, - "generate_undiscriminated_disjunctions": { - "type": "boolean", - "description": "GenerateUndiscriminatedDisjunctions controls whether disjunctions of refs\nwithout a discriminator field are generated as a union struct type.\nWhen false (default), such disjunctions are emitted as interface{}/any." } }, "additionalProperties": false, @@ -430,10 +426,6 @@ "type": "boolean", "description": "SkipRuntime disables runtime-related code generation when enabled.\nNote: builders can NOT be generated with this flag turned on, as they\nrely on the runtime to function." }, - "generate_equal": { - "type": "boolean", - "description": "GenerateEqual controls the generation of `equals()` and `hashCode()` methods on types." - }, "generate_json_marshaller": { "type": "boolean", "description": "GenerateJSONMarshaller controls the generation of `MarshalJSON()` and\n`UnmarshalJSON()` methods on types." @@ -474,10 +466,6 @@ "namespace_root": { "type": "string" }, - "generate_equal": { - "type": "boolean", - "description": "GenerateEqual controls the generation of `equals()` methods on types." - }, "generate_json_marshaller": { "type": "boolean", "description": "GenerateJSONMarshaller controls the generation of `fromArray()` and\n`jsonSerialize()` methods on types." @@ -512,10 +500,6 @@ "path_prefix": { "type": "string" }, - "generate_equal": { - "type": "boolean", - "description": "GenerateEqual controls the generation of `__eq__()` methods on types." - }, "generate_json_marshaller": { "type": "boolean", "description": "GenerateJSONMarshaller controls the generation of `to_json()` and\n`from_json()` methods on types." @@ -569,10 +553,6 @@ "type": "string", "description": "PathPrefix holds an optional prefix for all Typescript file paths generated.\nIf left undefined, `src` is used as a default prefix." }, - "generate_equal": { - "type": "boolean", - "description": "GenerateEqual controls the generation of `equalsTypeName()` functions for types." - }, "skip_runtime": { "type": "boolean", "description": "SkipRuntime disables runtime-related code generation when enabled.\nNote: builders can NOT be generated with this flag turned on, as they\nrely on the runtime to function." From 5e0bdf816405716338629109ae8721931fb5546d Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Wed, 20 May 2026 14:08:53 -0400 Subject: [PATCH 3/5] address copilot comments --- internal/jennies/zod/rawtypes.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/jennies/zod/rawtypes.go b/internal/jennies/zod/rawtypes.go index bce8ff480..f81d3f042 100644 --- a/internal/jennies/zod/rawtypes.go +++ b/internal/jennies/zod/rawtypes.go @@ -256,6 +256,12 @@ func (e *emitter) formatScalar(s ast.ScalarType) string { return fmt.Sprintf("z.literal(%s)", formatLiteral(s.Value)) } + for _, c := range s.Constraints { + if c.Op == ast.EqualOp { + return fmt.Sprintf("z.literal(%s)", formatLiteral(c.Args[0])) + } + } + switch s.ScalarKind { case ast.KindNull: return "z.null()" @@ -286,6 +292,8 @@ func applyStringConstraints(base string, cs []ast.TypeConstraint) string { base += fmt.Sprintf(".max(%s)", formatLiteral(c.Args[0])) case ast.RegexMatchOp: base += fmt.Sprintf(".regex(new RegExp(%s))", formatLiteral(c.Args[0])) + case ast.NotRegexMatchOp: + base += fmt.Sprintf(".refine((value) => !(new RegExp(%s)).test(value), { message: %q })", formatLiteral(c.Args[0]), "String must not match forbidden pattern") } } return base @@ -304,6 +312,10 @@ func applyNumberConstraints(base string, cs []ast.TypeConstraint) string { base += fmt.Sprintf(".gte(%s)", formatLiteral(c.Args[0])) case ast.MultipleOfOp: base += fmt.Sprintf(".multipleOf(%s)", formatLiteral(c.Args[0])) + case ast.NotEqualOp: + base += fmt.Sprintf(".refine((value) => value !== %s, { message: %q })", formatLiteral(c.Args[0]), "Value must not equal forbidden constant") + case ast.EqualOp: + base += fmt.Sprintf(".refine((value) => value === %s, { message: %q })", formatLiteral(c.Args[0]), "Value must equal required constant") } } return base @@ -349,6 +361,8 @@ func (e *emitter) formatArray(arr ast.ArrayType) string { base += fmt.Sprintf(".min(%s)", formatLiteral(c.Args[0])) case ast.MaxItemsOp: base += fmt.Sprintf(".max(%s)", formatLiteral(c.Args[0])) + case ast.UniqueItemsOp: + base += `.refine((items) => new Set(items.map((item) => JSON.stringify(item))).size === items.length, { message: "Array items must be unique" })` } } return base From e20fcd3991b253ea2470c5426e796fdbbd1d973a Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Wed, 20 May 2026 14:15:37 -0400 Subject: [PATCH 4/5] linting issues --- internal/jennies/zod/tools.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/jennies/zod/tools.go b/internal/jennies/zod/tools.go index 202a25835..6610f7871 100644 --- a/internal/jennies/zod/tools.go +++ b/internal/jennies/zod/tools.go @@ -47,7 +47,10 @@ func formatLiteral(val any) string { } switch v := val.(type) { case string: - b, _ := json.Marshal(v) + b, err := json.Marshal(v) + if err != nil { + return "null" + } return string(b) case bool: return fmt.Sprintf("%t", v) @@ -88,7 +91,10 @@ func quoteFieldName(name string) string { if jsIdentifier.MatchString(name) { return name } - b, _ := json.Marshal(name) + b, err := json.Marshal(name) + if err != nil { + return `""` + } return string(b) } From 64d64465bbec20f1f671d3c1305e035d2503bc1f Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Wed, 20 May 2026 15:28:10 -0400 Subject: [PATCH 5/5] cycles issue --- internal/jennies/zod/rawtypes.go | 80 +++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/internal/jennies/zod/rawtypes.go b/internal/jennies/zod/rawtypes.go index f81d3f042..73f355c2e 100644 --- a/internal/jennies/zod/rawtypes.go +++ b/internal/jennies/zod/rawtypes.go @@ -95,7 +95,10 @@ func (jenny RawTypes) generateSchema(context languages.Context, schema *ast.Sche ) } - header := "// @ts-nocheck\n" + header := "" + if schemaHasCycle(schema) { + header += "// @ts-nocheck\n" + } header += "import { z } from 'zod';\n" if importStatements := imports.String(); importStatements != "" { header += importStatements @@ -105,6 +108,81 @@ func (jenny RawTypes) generateSchema(context languages.Context, schema *ast.Sche return []byte(header + body.String()), nil } +// schemaHasCycle returns true when any object in the schema participates in a +// cycle via same-package references. Recursive Zod schemas built with z.lazy() +// trip TS strict-mode declaration-emit (TS7022/TS7024), and only the +// cycle-bearing files need the @ts-nocheck suppression. Cycle detection is a +// standard 3-color DFS over the same-package reference graph. +func schemaHasCycle(schema *ast.Schema) bool { + adj := make(map[string]map[string]struct{}) + schema.Objects.Iterate(func(name string, obj ast.Object) { + refs := make(map[string]struct{}) + collectSamePackageRefs(obj.Type, schema.Package, refs) + adj[name] = refs + }) + + const ( + white = 0 + gray = 1 + black = 2 + ) + color := make(map[string]int, len(adj)) + + var dfs func(string) bool + dfs = func(node string) bool { + switch color[node] { + case gray: + return true + case black: + return false + } + color[node] = gray + for next := range adj[node] { + if dfs(next) { + return true + } + } + color[node] = black + return false + } + + for node := range adj { + if color[node] == white && dfs(node) { + return true + } + } + return false +} + +// collectSamePackageRefs walks t and records every referenced object name in +// the same package. Cross-package refs and scalars are skipped because they +// cannot close a cycle within this file. +func collectSamePackageRefs(t ast.Type, pkg string, out map[string]struct{}) { + switch t.Kind { + case ast.KindRef: + r := t.AsRef() + if r.ReferredPkg == "" || r.ReferredPkg == pkg { + out[r.ReferredType] = struct{}{} + } + case ast.KindStruct: + for _, f := range t.AsStruct().Fields { + collectSamePackageRefs(f.Type, pkg, out) + } + case ast.KindArray: + collectSamePackageRefs(t.AsArray().ValueType, pkg, out) + case ast.KindMap: + collectSamePackageRefs(t.AsMap().ValueType, pkg, out) + case ast.KindDisjunction: + for _, b := range t.AsDisjunction().Branches { + collectSamePackageRefs(b, pkg, out) + } + case ast.KindIntersection: + for _, b := range t.AsIntersection().Branches { + collectSamePackageRefs(b, pkg, out) + } + } +} + // emitter walks the AST for one schema and produces Zod source as strings. type emitter struct { context languages.Context