fix(go): collapse flat response unions whose content types share one schema

Merged
AS
12h ago
+501-20

Why

With responseFormat: flat, an operation whose success response declares more than one content type gets a union of the possible body types. Union members are named by their type, so two non-JSON content types that share one schema (for example image/png and image/svg+xml, both type: string) produce two members named Str. The generated Go does not compile:

models/operations/getimage.go:24:2: Str redeclared
models/operations/getimage.go:39:6: CreateGetImageResponseStr redeclared in this block
models/operations/getimage.go:108:7: duplicate case GetImageResponseTypeStrValue in expression switch

The same happens with format: binary on both (ResponseStream redeclared). Removing one content type compiles, so the failure is in how the flat-response union is assembled, not in the string handler.

What changed

flattenResponseObject (pkg/generate/responses.go) now skips an unnamed scalar body type (an explicit list of item-less primitive data types plus bytes and response stream, or an array, set or map whose items are such a scalar, with no name, fields or members; JSONL, event streams, containers of objects, objects and unions never qualify) that is equal (TypeDef.IsEqual) to one already collected for the union. IsEqual ignores Format, and that is intended: a format difference (string vs string, format: email, or a bigint with and without format: string) changes neither the declared Go type nor the union member name, and each content-type branch decodes with its own field annotations, so such members would still collide. If only one distinct type remains, it is used directly as the response type instead of registering a one-member union; the method returns *string and each content-type branch returns the decoded body. Unions whose members differ (for example text/csv string plus application/json object) are unchanged, and so are unions of named inline objects that happen to be structurally equal across status codes: each status code deserializes into its own type, so those must stay members.

TypeDef.IsEqual (internal/ast/typedef.go) compares discriminator mapping counts before indexing the other union's mappings; it previously panicked when the left union had more mappings than the right.

The dedupe is gated to the go target. TypeScript and Python with responseFormat: flat compile a union of identical members today, and collapsing it there would drop an exported response type, so their output is untouched.

Testing

  • pkg/generate/flat_response_union_test.go (table per operation, Go with responseFormat: flat): collapses two type: string bodies, two strings whose format differs, a string beside a nullable string, two references to one scalar component, two format: binary strings, the same string on 200 and 202, two strings under a response with headers (the envelope's Result is the string), and two arrays of strings; keeps a two-member union with one Str member when two equal strings sit beside an object, and keeps two members for string plus inline enum, string plus object, structurally equal inline objects on 200 and 201, two JSONL bodies, and two arrays of inline objects. TypeScript and Python keep the two-member union. The Go collapse cases fail before the fix.
  • TestResolveAST now resolves direct-mode generation access and passes that context through document loading and AST resolution, so feature-gated content types such as JSONL resolve in harness tests instead of being silently skipped.
  • internal/ast/typedef_test.go: two discriminated unions with different mapping counts return a mismatch error and do not panic.
  • Regenerated the secondary Go test SDK locally with scripts/build-target.sh go secondary; go build ./... on the output passes and both status-code unions are generated as on main.
  • Generated a minimal spec (one GET returning image/png and image/svg+xml, both type: string) with the Go target and responseFormat: flat: go build ./... fails before the fix with the errors above and compiles after.
  • Generated a full production-sized spec containing such an operation with the Go target and responseFormat: flat; the output builds.
  • go test ./pkg/generate/ ./internal/ast/ and golangci-lint run ./pkg/generate/... pass.
  • templates/templates/go/includes/tests.ts: generated response assertions only append the union member accessor when the flat response type is a union, so an operation whose bodies collapsed to a scalar does not produce res.Str on a *string.
  • Generated a spec covering the remaining shapes with the Go target and responseFormat: flat: two format: binary strings, three content types (two strings plus an object), string plus nullable string, two strings with headers, the same string body on 200 and 202, two integers, strings with different validations, two arrays of strings, and two references to one scalar component. Every one fails to compile on main with a redeclaration and compiles after the fix, with the collapsed cases returning the scalar or slice directly and the mixed case keeping a two-member union.
  • Review SDKs not regenerated: the Go review fixture uses envelope-http, and the only review/uber operations with several content types either differ in schema or mark the extra content types ignored, so no tracked output changes.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.