← 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
A docs/solutions/design-patterns/identifier-collision-uniquification-pattern-2026-05-08.mdM internal/generator/generator.goM internal/generator/templates/types.go.tmplA internal/generator/type_collision.goA internal/generator/types_collision_test.goM internal/spec/spec.go
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 →