[object Object]

← back to Cli Printing Press

fix(cli): dedupe TypeField identifiers in same struct (#705)

d1c3371e13f9f558ff359d3d320a80ebef46d77f · 2026-05-08 00:22:45 -0700 · Trevin Chow

Two JSON keys differing only in non-alphanumeric leading punctuation
(e.g. GitHub's `+1`/`-1` in `reaction_rollup`) both collapsed to the Go
identifier `V1`, producing a duplicate-field-name struct that failed
`go vet` with "V1 redeclared" and blocked the validate gate.

Mirrors the flag-collision pattern from #275/#287: a generator pre-pass
walks `g.Spec.Types` and suffixes a hidden `IdentName` on `spec.TypeField`
when `toCamel(.Name)` collides with an earlier field in the same struct.
Templates render `{{camel (typeFieldIdent .)}}`, falling back to `Name`
when no collision occurred. JSON tags continue to read `Name` verbatim —
wire-side serialization is unchanged.

Also adds a docs/solutions/design-patterns/ entry documenting the pattern
across all three instances (#275, #287, #697) so the next collision class
doesn't re-derive it.

Fixes #697

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d1c3371e13f9f558ff359d3d320a80ebef46d77f
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 00:22:45 2026 -0700

    fix(cli): dedupe TypeField identifiers in same struct (#705)
    
    Two JSON keys differing only in non-alphanumeric leading punctuation
    (e.g. GitHub's `+1`/`-1` in `reaction_rollup`) both collapsed to the Go
    identifier `V1`, producing a duplicate-field-name struct that failed
    `go vet` with "V1 redeclared" and blocked the validate gate.
    
    Mirrors the flag-collision pattern from #275/#287: a generator pre-pass
    walks `g.Spec.Types` and suffixes a hidden `IdentName` on `spec.TypeField`
    when `toCamel(.Name)` collides with an earlier field in the same struct.
    Templates render `{{camel (typeFieldIdent .)}}`, falling back to `Name`
    when no collision occurred. JSON tags continue to read `Name` verbatim —
    wire-side serialization is unchanged.
    
    Also adds a docs/solutions/design-patterns/ entry documenting the pattern
    across all three instances (#275, #287, #697) so the next collision class
    doesn't re-derive it.
    
    Fixes #697
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 ...-collision-uniquification-pattern-2026-05-08.md | 217 +++++++++++++++++++++
 internal/generator/generator.go                    |   3 +
 internal/generator/templates/types.go.tmpl         |   2 +-
 internal/generator/type_collision.go               |  64 ++++++
 internal/generator/types_collision_test.go         | 207 ++++++++++++++++++++
 internal/spec/spec.go                              |   4 +
 6 files changed, 496 insertions(+), 1 deletion(-)

diff --git a/docs/solutions/design-patterns/identifier-collision-uniquification-pattern-2026-05-08.md b/docs/solutions/design-patterns/identifier-collision-uniquification-pattern-2026-05-08.md
new file mode 100644
index 00000000..08d3bae1
--- /dev/null
+++ b/docs/solutions/design-patterns/identifier-collision-uniquification-pattern-2026-05-08.md
@@ -0,0 +1,217 @@
+---
+title: "Generator identifier collisions: dedupe with a hidden IdentName field and a late pre-pass"
+date: 2026-05-08
+category: design-patterns
+module: cli-printing-press-generator
+problem_type: design_pattern
+component: tooling
+severity: medium
+applies_when:
+  - "Adding a new spec struct whose Name field flows through camel-casing into a Go identifier the generator emits"
+  - "Two or more spec entries in the same Go scope (struct, endpoint, namespace) can produce the same identifier through toCamel"
+  - "Wire-side serialization (json tags, URL params, GraphQL selections) must continue to read the original Name regardless of any disambiguation"
+  - "Golden output must stay byte-stable across regenerations even when source names are non-unique after sanitization"
+tags:
+  - identifier-collision
+  - code-generation
+  - go-identifiers
+  - deduplication
+  - ident-name
+  - generator-pre-pass
+  - deterministic-suffixing
+related_components:
+  - generator
+  - templates
+  - spec
+---
+
+# Generator identifier collisions: dedupe with a hidden IdentName field and a late pre-pass
+
+## Context
+
+The generator emits Go source from spec data. Spec field names — query params, body fields, struct field names — are written by API owners for wire-side semantics (URL keys, JSON object keys), not Go identifier legality. When `toCamel` normalizes two spec names to the same string, the generator emits two `var flag<X>` declarations or two struct fields with identical names, which fail to compile.
+
+The codebase has reached for this collision problem three separate times across different spec classes:
+
+- **#275 F-2** — endpoint query/path params (Twilio's `StartTime`, `StartTime>`, `StartTime<` all collapse to `StartTime`).
+- **#287** — request body fields (`start_time` and `StartTime` both yield `bodyStartTime`).
+- **#697** — shared-type struct fields (GitHub Reactions `+1` and `-1` both yield `V1`).
+
+Each recurrence reached for the same structural pattern: a hidden override field, a generator pre-pass, a fallback helper. After three instances, the pattern is durable enough to write down so the next collision class doesn't re-derive it.
+
+## Guidance
+
+The pattern has three fixed components.
+
+### 1. An `IdentName string` override field on the spec struct, tagged for invisibility
+
+```go
+// internal/spec/spec.go
+type Param struct {
+    Name      string
+    // ...
+    IdentName string `yaml:"-" json:"-"`
+}
+
+type TypeField struct {
+    Name      string
+    // ...
+    IdentName string `yaml:"-" json:"-"`
+}
+```
+
+The `yaml:"-" json:"-"` tags mean the field never lands in manuscript YAML, in catalog JSON, or in any agent-authored file. It is purely a generator-internal scratch slot.
+
+### 2. A generator pre-pass that walks the spec and suffixes IdentName on collisions
+
+The pre-pass is a method on `*Generator` invoked from `prepareOutput()`. It keeps a `used` map of camelized identifiers already claimed in the current scope. The first occurrence of a camel form keeps `IdentName` empty. Each subsequent collision tries `Name + "_2"`, `"_3"`, ... until the candidate's camel form is unused.
+
+```go
+// internal/generator/type_collision.go (#697)
+func uniquifyTypeFieldIdentifiers(fields []spec.TypeField) []spec.TypeField {
+    used := make(map[string]struct{}, len(fields))
+    out := make([]spec.TypeField, len(fields))
+    for i, f := range fields {
+        f.IdentName = ""              // idempotence guard
+        ident := toCamel(f.Name)
+        if _, taken := used[ident]; !taken {
+            used[ident] = struct{}{}
+            out[i] = f
+            continue
+        }
+        for n := 2; ; n++ {
+            candidate := fmt.Sprintf("%s_%d", f.Name, n)
+            if _, taken := used[toCamel(candidate)]; !taken {
+                f.IdentName = candidate
+                used[toCamel(candidate)] = struct{}{}
+                out[i] = f
+                break
+            }
+        }
+    }
+    return out
+}
+```
+
+The suffix is appended to `Name` and re-camelized, not to the camelized form directly. That preserves the convention that `IdentName` is a *raw name* the same shape as `Name`, just disambiguated.
+
+### 3. A fallback helper in the template FuncMap
+
+```go
+func paramIdent(p spec.Param) string {
+    if p.IdentName != "" { return p.IdentName }
+    return p.Name
+}
+
+func typeFieldIdent(f spec.TypeField) string {
+    if f.IdentName != "" { return f.IdentName }
+    return f.Name
+}
+```
+
+Templates use the helper for Go identifiers and pass `Name` directly for wire-side serialization:
+
+```go-template
+// types.go.tmpl
+{{camel (typeFieldIdent .)}} {{goStructType .Type}} `json:"{{.Name}}"`
+
+// command_endpoint.go.tmpl
+var flag{{camel (paramIdent .)}} {{goTypeForParam .Name .Type}}
+```
+
+### Wiring
+
+The pre-passes run late in `prepareOutput()` (after `DetectAsyncJobs`) so they can take into account any reserved identifiers the generator introduces dynamically (`flagAll` for paginated endpoints, `flagWait*` for async ones). For TypeField, no such reservation exists; the call is unconditional.
+
+```go
+// internal/generator/generator.go, prepareOutput()
+if err := g.dedupeFlagIdentifiers(); err != nil {
+    return err
+}
+g.dedupeTypeFieldIdentifiers()
+```
+
+## Why This Matters
+
+Not following this pattern produces compile failures, not just bad output. Duplicate `var flag<X>` declarations at package scope and duplicate Go struct fields are hard `go vet`/`go build` errors that surface only at the quality-gate stage, after generation has run cleanly.
+
+The critical invariant is that **`Name` is never mutated**. Wire-side serialization reads `Name` in every template:
+
+- `\`json:"{{.Name}}"\`` in struct field tags
+- `params["{{.Name}}"] = ...` in URL-query construction
+- `path = replacePathParam(path, "{{.Name}}", ...)` in path substitution
+- GraphQL `{{.Name}} { ... }` selection blocks
+
+If a future maintainer "fixes" a collision by mutating `Name`, every printed CLI starts shipping wrong API calls — wrong URL keys, renamed JSON tags, broken GraphQL queries — and the failure mode is silent at compile time. The override-field shape exists specifically to prevent that.
+
+The deterministic `_2`, `_3`, ... suffix is also load-bearing: golden output stability depends on the same spec producing the same generated code across runs. Random or set-iteration-ordered suffixes would cause golden drift on every regeneration.
+
+## When to Apply
+
+Add a new dedup pass when **all** of these hold:
+
+1. The generator emits a new class of Go identifier from spec data (a new template loop doing `var <prefix>{{camel .Name}}` or a new struct type whose fields come from spec names).
+2. Two or more spec entries in the same Go scope can map to the same `toCamel(...)` form.
+3. Wire-side keys (JSON, URL params, GraphQL) must remain on the original `Name`.
+
+Concrete signal: ask "does `toCamel(A) == toCamel(B)` for any plausible pair of entries in the same scope?" If yes, the pattern is needed.
+
+Do **not** apply when:
+
+- The generator authors the name itself (generator-introduced identifiers like `stdinBody`, `flagAll` are reserved explicitly via the dedup pass's reserved sets, not added as authored entries).
+- The collision class is already covered by an existing pre-pass — extend the existing one rather than adding a parallel structure (issue #287 extended `flag_collision.go` to body fields rather than creating `body_collision.go`).
+- A simpler `toCamel` change would cover the case without dedup. It usually won't — sanitization is intentionally lossy.
+
+## Examples
+
+### Instance 1 — endpoint query/path params (`flag_collision.go`, #275 F-2)
+
+- Dedupes: two params on the same endpoint whose `toCamel(.Name)` collides, or whose cobra flag name collides with a generator-introduced reserved name.
+- Pre-pass wired at: `internal/generator/generator.go::prepareOutput` → `g.dedupeFlagIdentifiers()`.
+- Helper consumed in: `internal/generator/templates/command_endpoint.go.tmpl` and `command_promoted.go.tmpl` — `var flag{{camel (paramIdent .)}}`.
+- Namespace managed: two-pass (params first, then body fields), sharing the cobra flag-name namespace across both.
+
+### Instance 2 — body fields cross-namespace (#287)
+
+- Extended the same `flag_collision.go` / `uniquifyIdentifiers` to body fields as Pass 2 in `dedupeEndpointIdentifiers`. Body fields use the `body<Camel>` Go-identifier namespace and share the cobra flag-name namespace with already-processed params.
+- No new file or struct — the pattern absorbed the new collision class by widening the existing pass.
+
+### Instance 3 — shared-type struct fields (`type_collision.go`, #697)
+
+- Dedupes: two `spec.TypeField` entries in the same `spec.TypeDef` whose `toCamel(.Name)` collides.
+- Pre-pass wired at: `internal/generator/generator.go::prepareOutput` → `g.dedupeTypeFieldIdentifiers()`.
+- Helper consumed in: `internal/generator/templates/types.go.tmpl` — `{{camel (typeFieldIdent .)}}`.
+- JSON tag preserves `Name` verbatim: `\`json:"{{.Name}}"\``.
+
+### Test convention — assert non-colliding fields survive unchanged
+
+Every uniquifier test must include a fixture that should *not* be modified and assert it passes through untouched. Without it, a regression that over-eagerly suffixes every field would still pass collision-only tests. The boundary case is the regression guard.
+
+```go
+// internal/generator/types_collision_test.go
+{
+    name: "unrelated fields untouched",
+    input: []spec.TypeField{
+        {Name: "id"},
+        {Name: "name"},
+    },
+    wantIdents: []string{"Id", "Name"},  // plain toCamel, no IdentName set
+},
+```
+
+The test should also assert `Name` itself is never mutated across all cases:
+
+```go
+for i, f := range out {
+    got[i] = toCamel(typeFieldIdent(f))
+    assert.Equal(t, tc.input[i].Name, f.Name, "Name must never be mutated")
+}
+```
+
+(auto memory [claude]: the "include fixtures that should NOT be modified" rule comes from prior find-and-replace test conventions and applies to any rewrite/replace function.)
+
+## Related
+
+- `docs/solutions/design-patterns/dual-key-identity-fields-2026-05-06.md` — shares the structural mechanism (a hidden spec field decouples rendering from the wire name) but solves a different problem. There the override is user-authored at generate time and carries a semantically distinct form of the same value (slug vs. display name). Here the override is generator-computed in a pre-pass and exists solely to resolve identifier collisions; the suffix is mechanical, not semantic.
+- GitHub issues: [#275](https://github.com/mvanhorn/cli-printing-press/issues/275) (F-2 param collisions), [#287](https://github.com/mvanhorn/cli-printing-press/issues/287) (body field collisions), [#697](https://github.com/mvanhorn/cli-printing-press/issues/697) (TypeField collisions).
+- Source files: `internal/generator/flag_collision.go`, `internal/generator/type_collision.go`, `internal/spec/spec.go` (`Param.IdentName`, `TypeField.IdentName`).
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 98782599..94c97f11 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -235,6 +235,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"mcpParamDesc":           g.mcpParamDescription,
 		"flagName":               flagName,
 		"paramIdent":             paramIdent,
+		"typeFieldIdent":         typeFieldIdent,
 		"safeTypeName":           safeTypeName,
 		"hasNonScalarType": func(types map[string]spec.TypeDef) bool {
 			for _, td := range types {
@@ -1325,6 +1326,8 @@ func (g *Generator) prepareOutput() error {
 		return err
 	}
 
+	g.dedupeTypeFieldIdentifiers()
+
 	return nil
 }
 
diff --git a/internal/generator/templates/types.go.tmpl b/internal/generator/templates/types.go.tmpl
index 7fc7033a..6bea619b 100644
--- a/internal/generator/templates/types.go.tmpl
+++ b/internal/generator/templates/types.go.tmpl
@@ -8,7 +8,7 @@ import "encoding/json"
 {{range $name, $typeDef := .Types}}
 type {{safeTypeName $name}} struct {
 {{- range $typeDef.Fields}}
-	{{camel .Name}} {{goStructType .Type}} `json:"{{.Name}}"`
+	{{camel (typeFieldIdent .)}} {{goStructType .Type}} `json:"{{.Name}}"`
 {{- end}}
 }
 {{end}}
diff --git a/internal/generator/type_collision.go b/internal/generator/type_collision.go
new file mode 100644
index 00000000..e0b13d30
--- /dev/null
+++ b/internal/generator/type_collision.go
@@ -0,0 +1,64 @@
+package generator
+
+import (
+	"fmt"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// dedupeTypeFieldIdentifiers ensures every TypeField in g.Spec.Types
+// produces a Go identifier unique within its struct under toCamel. Without
+// this, JSON keys differing only in leading non-alphanumeric punctuation
+// (e.g. +1 and -1) emit two struct fields with the same Go name.
+func (g *Generator) dedupeTypeFieldIdentifiers() {
+	if g.Spec == nil || len(g.Spec.Types) == 0 {
+		return
+	}
+	for typeName, td := range g.Spec.Types {
+		td.Fields = uniquifyTypeFieldIdentifiers(td.Fields)
+		g.Spec.Types[typeName] = td
+	}
+}
+
+// uniquifyTypeFieldIdentifiers suffixes IdentName on later-occurring
+// fields whose toCamel collides with an earlier one, leaving Name and
+// thus the JSON tag unchanged. Caller must pass fields in deterministic
+// order; suffixes are assigned in encounter order.
+func uniquifyTypeFieldIdentifiers(fields []spec.TypeField) []spec.TypeField {
+	if len(fields) == 0 {
+		return fields
+	}
+	used := make(map[string]struct{}, len(fields))
+	out := make([]spec.TypeField, len(fields))
+	for i, f := range fields {
+		f.IdentName = "" // ensure idempotence when callers reuse the same APISpec
+		ident := toCamel(f.Name)
+		if _, taken := used[ident]; !taken {
+			used[ident] = struct{}{}
+			out[i] = f
+			continue
+		}
+		for n := 2; ; n++ {
+			candidate := fmt.Sprintf("%s_%d", f.Name, n)
+			candidateIdent := toCamel(candidate)
+			if _, taken := used[candidateIdent]; !taken {
+				f.IdentName = candidate
+				used[candidateIdent] = struct{}{}
+				out[i] = f
+				break
+			}
+		}
+	}
+	return out
+}
+
+// typeFieldIdent mirrors paramIdent: prefer IdentName when the dedup
+// pass populated it, otherwise fall back to Name. The result is for Go
+// identifier derivation only; wire-side serialization (json tags, GraphQL
+// selections) always reads Name directly.
+func typeFieldIdent(f spec.TypeField) string {
+	if f.IdentName != "" {
+		return f.IdentName
+	}
+	return f.Name
+}
diff --git a/internal/generator/types_collision_test.go b/internal/generator/types_collision_test.go
new file mode 100644
index 00000000..955cb608
--- /dev/null
+++ b/internal/generator/types_collision_test.go
@@ -0,0 +1,207 @@
+package generator
+
+import (
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"os"
+	"path/filepath"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestGenerateDeduplicatesCamelCollidingTypeFields runs an end-to-end
+// generation on a TypeDef whose JSON keys collide under toCamel, then
+// AST-walks the emitted struct to confirm: (a) every input field is
+// represented, (b) every Go identifier is unique, and (c) JSON tags are
+// preserved verbatim. Subprocess-level Go syntax is checked by parsing.
+func TestGenerateDeduplicatesCamelCollidingTypeFields(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("collide-types")
+	apiSpec.Types = map[string]spec.TypeDef{
+		"reaction_rollup": {
+			Fields: []spec.TypeField{
+				{Name: "+1", Type: "integer"},
+				{Name: "-1", Type: "integer"},
+				{Name: "confused", Type: "integer"},
+				{Name: "laugh", Type: "integer"},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "collide-types-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	typesPath := filepath.Join(outputDir, "internal", "types", "types.go")
+	src, err := os.ReadFile(typesPath)
+	require.NoError(t, err)
+
+	fields, jsonTags := structFieldsByTypeName(t, typesPath, src, "reaction_rollup")
+	assert.ElementsMatch(t, []string{"+1", "-1", "confused", "laugh"}, jsonTags)
+	require.Len(t, fields, 4)
+	assertNoDuplicates(t, fields, "every Go field name must be unique")
+}
+
+// TestGenerateDeduplicatesCollidingNonScalarTypeFields covers the case
+// where colliding fields are non-scalar (object/array) types, which take
+// the json.RawMessage path in goStructType and require the
+// "encoding/json" import to be emitted.
+func TestGenerateDeduplicatesCollidingNonScalarTypeFields(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("collide-nonscalar")
+	apiSpec.Types = map[string]spec.TypeDef{
+		"wrapper": {
+			Fields: []spec.TypeField{
+				{Name: "+1", Type: "object"},
+				{Name: "-1", Type: "object"},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "collide-nonscalar-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	typesPath := filepath.Join(outputDir, "internal", "types", "types.go")
+	src, err := os.ReadFile(typesPath)
+	require.NoError(t, err)
+
+	fields, _ := structFieldsByTypeName(t, typesPath, src, "wrapper")
+	require.Len(t, fields, 2)
+	assertNoDuplicates(t, fields, "non-scalar colliding fields must produce distinct idents")
+	assert.Contains(t, string(src), `import "encoding/json"`,
+		"the json import must be emitted when any field is non-scalar, even after dedup")
+}
+
+// TestUniquifyTypeFieldIdentifiers exercises the dedup logic directly,
+// avoiding a full generator round-trip per case.
+func TestUniquifyTypeFieldIdentifiers(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name       string
+		input      []spec.TypeField
+		wantIdents []string // toCamel of (IdentName || Name) per field
+	}{
+		{
+			name:       "single special-prefix field keeps canonical V1",
+			input:      []spec.TypeField{{Name: "+1"}},
+			wantIdents: []string{"V1"},
+		},
+		{
+			name: "paired punctuation prefixes resolve to distinct idents",
+			input: []spec.TypeField{
+				{Name: "+1"},
+				{Name: "-1"},
+			},
+			wantIdents: []string{"V1", "V12"},
+		},
+		{
+			name: "multi-class collisions across pairs",
+			input: []spec.TypeField{
+				{Name: "*key"},
+				{Name: "@key"},
+				{Name: "%val"},
+				{Name: "!val"},
+			},
+			wantIdents: []string{"Key", "Key2", "Val", "Val2"},
+		},
+		{
+			name: "unrelated fields untouched",
+			input: []spec.TypeField{
+				{Name: "id"},
+				{Name: "name"},
+			},
+			wantIdents: []string{"Id", "Name"},
+		},
+		{
+			name:       "empty fields",
+			input:      []spec.TypeField{},
+			wantIdents: []string{},
+		},
+		{
+			name: "three-way collision walks suffixes until distinct",
+			input: []spec.TypeField{
+				{Name: "+1"},
+				{Name: "-1"},
+				{Name: "*1"},
+			},
+			wantIdents: []string{"V1", "V12", "V13"},
+		},
+		{
+			name: "suffix candidate collides with a literal sibling",
+			input: []spec.TypeField{
+				{Name: "+1"},
+				{Name: "-1"},
+				{Name: "12"},
+			},
+			wantIdents: []string{"V1", "V12", "V122"},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			out := uniquifyTypeFieldIdentifiers(tc.input)
+			require.Len(t, out, len(tc.wantIdents))
+			got := make([]string, len(out))
+			for i, f := range out {
+				got[i] = toCamel(typeFieldIdent(f))
+				assert.Equal(t, tc.input[i].Name, f.Name, "Name must never be mutated")
+			}
+			assert.Equal(t, tc.wantIdents, got)
+		})
+	}
+}
+
+// structFieldsByTypeName returns the Go field identifiers and JSON tag
+// values for the named struct in the parsed source. The lookup is routed
+// through safeTypeName so callers pass the spec map key and the helper
+// handles any name transform the template applied (hyphens, dots, etc.).
+func structFieldsByTypeName(t *testing.T, path string, src []byte, typeName string) (fields, jsonTags []string) {
+	t.Helper()
+	fset := token.NewFileSet()
+	file, err := parser.ParseFile(fset, path, src, 0)
+	require.NoError(t, err)
+
+	expected := safeTypeName(typeName)
+	for _, decl := range file.Decls {
+		gen, ok := decl.(*ast.GenDecl)
+		if !ok || gen.Tok != token.TYPE {
+			continue
+		}
+		for _, sp := range gen.Specs {
+			ts, ok := sp.(*ast.TypeSpec)
+			if !ok || ts.Name.Name != expected {
+				continue
+			}
+			st, ok := ts.Type.(*ast.StructType)
+			if !ok {
+				continue
+			}
+			for _, f := range st.Fields.List {
+				for _, n := range f.Names {
+					fields = append(fields, n.Name)
+				}
+				if f.Tag != nil {
+					tag := reflect.StructTag(strings.Trim(f.Tag.Value, "`"))
+					if json := tag.Get("json"); json != "" {
+						if comma := strings.Index(json, ","); comma >= 0 {
+							json = json[:comma]
+						}
+						jsonTags = append(jsonTags, json)
+					}
+				}
+			}
+			return fields, jsonTags
+		}
+	}
+	t.Fatalf("type %q not found in %s", typeName, path)
+	return nil, nil
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index e9095023..bdd323a7 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -1091,6 +1091,10 @@ type TypeField struct {
 	// field simple (for example, totalPriceSet as json.RawMessage) while still
 	// issuing valid nested GraphQL selections.
 	Selection string `yaml:"selection,omitempty" json:"selection,omitempty"`
+	// IdentName overrides Name for Go-identifier derivation when two field
+	// names in the same struct sanitize to the same identifier through
+	// camel-casing. Wire-side serialization always reads Name.
+	IdentName string `yaml:"-" json:"-"`
 }
 
 func Parse(path string) (*APISpec, error) {

← 35d7e842 fix(cli): gate orphan token scaffolding on auth surface (#70  ·  back to Cli Printing Press  ·  fix(cli): correct named-array envelope detection and api_key c46cf015 →