feat(jsontypes): simplify map detection to 3 rules, add API test fixtures

Rewrote looksLikeMap from 6 layered heuristics down to 3 simple rules:
1. All numeric keys → map (certain)
2. Any key composed of words → struct (certain)
3. Everything else → map (safe default)

The asymmetry is deliberate: a map misidentified as a struct explodes
into hundreds of fields, while a struct misidentified as a map is
compact and easy to fix.

Word detection splits keys on _ and camelCase, checks segments for
pronounceability (vowel/consonant rhythm). Short words (id, at, on)
only count when paired with a strong 3+ char word.

Also adds raw path type naming with Item suffix for collections
(FriendsItem instead of Friend), real API test fixtures from PokeAPI
and SWAPI, and UUID-based test data replacing synthetic abc123 keys.
This commit is contained in:
AJ ONeal 2026-03-07 23:05:47 -07:00
parent 767115586c
commit 439558a8ac
No known key found for this signature in database
9 changed files with 275 additions and 297 deletions

View File

@ -94,11 +94,11 @@ func TestAnalyzeSimpleStruct(t *testing.T) {
func TestAnalyzeMapDetection(t *testing.T) {
a := testAnalyzer(t)
// Keys with digits + same length → detected as map
// UUID-like keys → detected as map
obj := map[string]any{
"abc123": map[string]any{"name": "a"},
"def456": map[string]any{"name": "b"},
"ghi789": map[string]any{"name": "c"},
"a1b2c3d4-e5f6-7890-abcd-ef1234567890": map[string]any{"name": "a"},
"b2c3d4e5-f6a7-8901-bcde-f12345678901": map[string]any{"name": "b"},
"c3d4e5f6-a7b8-9012-cdef-123456789012": map[string]any{"name": "c"},
}
paths := sortPaths(a.Analyze(".", obj))
want := sortPaths([]string{
@ -172,9 +172,9 @@ func TestHeuristicsMapDetection(t *testing.T) {
wantConf bool
}{
{"numeric keys", []string{"1", "2", "3"}, true, true},
{"alphanum IDs", []string{"abc123", "def456", "ghi789"}, true, true},
{"uuid keys", []string{"a1b2c3d4-e5f6", "b2c3d4e5-f6a7", "c3d4e5f6-a7b8"}, true, true},
{"field names", []string{"name", "age", "email"}, false, true},
{"two keys", []string{"ab", "cd"}, false, false},
{"two keys no words", []string{"ab", "cd"}, true, false},
{"hex IDs", []string{"a1b2c3d4", "e5f6a7b8", "c9d0e1f2"}, true, true},
}
for _, tt := range tests {
@ -347,7 +347,7 @@ func TestAutoResolveCollision(t *testing.T) {
// Analyze an object at a path under {Document} that would infer "Room"
// but has completely different fields — should auto-resolve to "DocumentRoom"
obj := map[string]any{"x": "1", "y": "2"}
obj := map[string]any{"width": "1", "height": "2"}
paths := a.Analyze(".{Document}.room", obj)
hasDocumentRoom := false
@ -391,7 +391,7 @@ func TestAnalyzeFullSample(t *testing.T) {
a := testAnalyzer(t)
data := map[string]any{
"abc123": map[string]any{
"550e8400-e29b-41d4-a716-446655440000": map[string]any{
"name": "Alice",
"age": jsonNum("30"),
"active": true,
@ -402,10 +402,10 @@ func TestAnalyzeFullSample(t *testing.T) {
}},
},
},
"def456": map[string]any{
"6ba7b810-9dad-11d1-80b4-00c04fd430c8": map[string]any{
"name": "Dave", "age": jsonNum("25"), "active": false, "friends": []any{},
},
"ghi789": map[string]any{
"f47ac10b-58cc-4372-a567-0e02b2c3d479": map[string]any{
"name": "Eve", "age": jsonNum("28"), "active": true, "score": jsonNum("95.5"),
"friends": []any{
map[string]any{"name": "Frank", "identification": map[string]any{
@ -517,11 +517,11 @@ func TestDecideMapOrStructDefault(t *testing.T) {
},
})
// Use 2 keys that aren't known field names — ambiguous enough to
// trigger the resolver (not confident either way).
// Use 2 short keys that aren't recognized as words — ambiguous enough
// to trigger the resolver (map default, not confident).
obj := map[string]any{
"foo": []any{},
"bar": []any{map[string]any{"x": "y"}},
"ab": []any{},
"cd": []any{map[string]any{"name": "y"}},
}
a.Analyze(".", obj)

View File

@ -120,7 +120,7 @@ func TestGoStructRoundTrip(t *testing.T) {
},
{
"all_primitives",
`{"s": "hello", "i": 42, "f": 1.5, "b": true, "n": null}`,
`{"name": "hello", "count": 42, "ratio": 1.5, "active": true, "extra": null}`,
},
{
"string_array",
@ -132,7 +132,7 @@ func TestGoStructRoundTrip(t *testing.T) {
},
{
"deeply_nested",
`{"a": {"b": {"c": {"d": "leaf"}}}}`,
`{"config": {"server": {"host": {"name": "leaf"}}}}`,
},
{
"mixed_optional_struct",
@ -834,7 +834,7 @@ func TestGoStructRoundTripMixedIntFloat(t *testing.T) {
// --- Round-trip with maps ---
func TestGoStructRoundTripMap(t *testing.T) {
jsonStr := `{"data": {"abc123": {"name": "foo", "active": true}, "def456": {"name": "bar", "active": false}, "ghi789": {"name": "baz", "active": true}, "jkl012": {"name": "qux", "active": false}}}`
jsonStr := `{"data": {"550e8400-e29b": {"name": "foo", "active": true}, "6ba7b810-9dad": {"name": "bar", "active": false}, "f47ac10b-58cc": {"name": "baz", "active": true}, "c3d4e5f6-a7b8": {"name": "qux", "active": false}}}`
goCode := GenerateGoStructsFromJSON(t, jsonStr)
t.Logf("generated:\n%s", goCode)

View File

@ -7,14 +7,25 @@ import (
"unicode"
)
// looksLikeMap uses heuristics to guess whether an object is a map (keyed
// collection) rather than a struct. Returns true/false and a confidence hint.
// If confidence is low, the caller should prompt the user.
// looksLikeMap uses heuristics to decide whether an object is a map (keyed
// collection) or a struct. Returns (isMap, confident).
//
// The logic is deliberately asymmetric: a map misidentified as a struct
// explodes into hundreds of fields, while a struct misidentified as a map
// is compact and easy to fix. So when in doubt, default to map.
//
// Three rules:
// 1. All numeric keys → map (certain).
// 2. Any key is a common word → struct (certain).
// 3. Everything else → map (safe default).
func looksLikeMap(obj map[string]any) (isMap bool, confident bool) {
keys := sortedKeys(obj)
n := len(keys)
if n == 0 {
return false, false
}
// All keys are integers → always a map, even with 1 key.
// Rule 1: All keys are integers → always a map.
allInts := true
for _, k := range keys {
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
@ -22,247 +33,105 @@ func looksLikeMap(obj map[string]any) (isMap bool, confident bool) {
break
}
}
if allInts && n > 0 {
if allInts {
return true, true
}
// If any key is a well-known struct field name, it's definitely a struct.
// This catches 1-2 key objects like {"name": ..., "age": ...}.
if hasKnownFieldName(keys) {
// Rule 2: Any key is composed of words → struct.
if hasWordLikeKey(keys) {
return false, true
}
// All keys contain mixed letters+digits → likely IDs (e.g., "emp_101").
if allContainDigits(keys) {
return true, true
}
// All keys same length and look like base64/hex IDs.
if allSameLength(keys) && allLookLikeIDs(keys) {
return true, true
}
if n < 3 {
// Too few keys and no strong signal either way.
return false, false
}
// Keys look like typical struct field names (camelCase, snake_case, short words).
// This must be checked before value-shape heuristics: a struct with many
// fields whose values happen to share a shape is still a struct.
if allLookLikeFieldNames(keys) {
return false, true
}
// Large number of keys where most values have the same shape → likely a map.
if n > 20 && valuesHaveSimilarShape(obj) {
return true, true
}
return false, false
// Rule 3: No recognized words → default to map.
// Confident when there are enough keys that we'd expect to find a word
// if this were really a struct.
return true, n >= 3
}
// hasKnownFieldName returns true if any key matches a well-known struct field
// name. A single match is a strong signal — real maps don't have keys named
// "created_at" or "email".
func hasKnownFieldName(keys []string) bool {
// hasWordLikeKey returns true if any key looks like it's composed of words
// (i.e., a struct field name rather than an ID/token). It splits on _ and
// camelCase boundaries, then checks if the segments are recognizable words.
func hasWordLikeKey(keys []string) bool {
for _, k := range keys {
if isKnownFieldName(k) {
if isWordLikeKey(k) {
return true
}
}
return false
}
// isKnownFieldName checks whether a key is a common struct/object field name.
// This is deliberately broad — false positives (calling a map key a field)
// are less harmful than false negatives (treating a struct as a map).
func isKnownFieldName(k string) bool {
lower := strings.ToLower(k)
// Suffix patterns: timestamps, flags, relations
suffixes := []string{
"_at", "_on", "_by", "_id", "_ids", "_url", "_uri",
"_name", "_type", "_kind", "_key", "_code", "_date",
"_count", "_size", "_path", "_hash", "_token",
"_enabled", "_disabled", "_active", "_status",
}
for _, s := range suffixes {
if strings.HasSuffix(lower, s) {
return true
}
}
// camelCase suffixes
camelSuffixes := []string{
"At", "On", "By", "Id", "Ids", "Url", "Uri",
"Name", "Type", "Kind", "Key", "Code", "Date",
"Count", "Size", "Path", "Hash", "Token",
}
for _, s := range camelSuffixes {
if strings.HasSuffix(k, s) && len(k) > len(s) {
return true
}
}
// Exact matches: common field names across APIs
switch lower {
case
// identity
"id", "uid", "uuid", "guid", "slug",
// naming
"name", "title", "label", "description", "summary",
// typing/classification
"type", "kind", "category", "class", "role", "status", "state",
// content
"value", "data", "content", "body", "text", "message", "comment",
"url", "uri", "href", "link", "path",
"email", "phone", "address",
// flags
"active", "enabled", "disabled", "visible", "hidden",
"required", "optional", "readonly", "deleted", "archived",
"public", "private", "verified", "approved", "published",
// numbers
"count", "total", "size", "length", "width", "height",
"amount", "price", "cost", "quantity", "weight",
"score", "rating", "level", "priority", "order", "index", "position",
"version", "revision",
"min", "max", "limit", "offset", "page",
"latitude", "longitude", "lat", "lng", "lon",
// structure
"parent", "children", "items", "entries", "results", "records",
"tags", "labels", "groups", "members", "users", "roles",
"permissions", "scopes", "features", "options", "settings",
"config", "configuration", "preferences", "metadata", "meta",
"errors", "warnings",
// media
"format", "encoding", "charset", "locale", "language", "currency",
"color", "icon", "image", "avatar", "thumbnail", "logo",
"filename", "extension", "mimetype",
// auth
"username", "password", "token", "secret", "credential",
// time
"date", "time", "timestamp", "duration", "interval",
"start", "end", "expires":
return true
}
// Prefix patterns: is_*, has_*, can_*, should_*, allow_*
prefixes := []string{"is_", "has_", "can_", "should_", "allow_", "num_"}
for _, p := range prefixes {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
// allContainDigits checks if every key contains at least one digit,
// suggesting they are IDs rather than field names (e.g., "emp_101", "abc123").
func allContainDigits(keys []string) bool {
if len(keys) == 0 {
return false
}
for _, k := range keys {
hasDigit := false
for _, r := range k {
if unicode.IsDigit(r) {
hasDigit = true
break
}
}
if !hasDigit {
return false
}
}
return true
}
func allSameLength(keys []string) bool {
if len(keys) == 0 {
return true
}
l := len(keys[0])
for _, k := range keys[1:] {
if len(k) != l {
return false
}
}
return true
}
// allLookLikeIDs checks if keys look like identifiers/tokens rather than field
// names. Uses hex detection, pronounceability, and field name checks.
func allLookLikeIDs(keys []string) bool {
for _, k := range keys {
if strings.ContainsAny(k, " \t\n") {
return false
}
// Pure hex is always an ID.
if len(k) >= 4 && isHex(k) {
continue
}
// Pronounceable strings are field names, not IDs.
if isPronounceable(k) {
return false
}
// Non-pronounceable alphanumeric of sufficient length → likely ID.
if len(k) >= 4 && isAlphanumeric(k) {
continue
}
// Doesn't match any ID pattern.
return false
}
return len(keys) > 0
}
func isAlphanumeric(s string) bool {
for _, r := range s {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return false
}
}
return true
}
// isPronounceable checks whether a string has the vowel/consonant rhythm of
// natural language. Field names like "metadata", "created_at", "userName" are
// pronounceable; tokens like "a1b2c3d4", "dGVzdA==", "xK9mP4q" are not.
// isWordLikeKey checks whether a key is composed of word-like segments,
// with at least one strong word (3+ chars, pronounceable). Short words
// like "at", "on", "id" only count when paired with a strong word
// (e.g., "created_at" has "created" as the strong word).
//
// The check splits on underscores and case boundaries (camelCase), then
// verifies each word-like segment has a reasonable vowel ratio (15-80%) and
// no long consonant runs (>4). Pure-digit segments are ignored.
func isPronounceable(s string) bool {
// Split on underscores, then on camelCase boundaries.
segments := splitWordSegments(s)
// "created_at" → ["created", "at"] → "created" is strong → true
// "theme" → ["theme"] → strong word → true
// "at" → ["at"] → no strong word → false
// "usr_2NBv8C" → ["usr", "2NBv8C"] → "2NBv8C" not a word → false
func isWordLikeKey(k string) bool {
segments := splitWordSegments(k)
if len(segments) == 0 {
return false
}
pronounceable := 0
total := 0
hasStrongWord := false
for _, seg := range segments {
// Skip pure digits and very short segments.
if len(seg) < 2 || isAllDigits(seg) {
if isAllDigits(seg) {
// Pure digit segments are fine (e.g., trailing numbers in "form1065"
// which splits to ["form", "1065"] after camelCase split — but
// splitWordSegments doesn't split on digit boundaries, so this
// mainly catches segments from underscore splits like "line_2").
continue
}
total++
if segmentIsPronounceable(seg) {
pronounceable++
if !isWordSegment(seg) {
return false
}
if len(seg) >= 3 && segmentIsPronounceable(seg) {
hasStrongWord = true
}
}
return hasStrongWord
}
if total == 0 {
// commonShortWords are words too short for pronounceability checks but
// commonly found in struct field names.
var commonShortWords = map[string]bool{
"id": true, "at": true, "on": true, "by": true, "in": true,
"to": true, "of": true, "or": true, "is": true, "no": true,
"do": true, "up": true, "if": true, "go": true, "ok": true,
}
// isWordSegment returns true if a segment looks like a word.
func isWordSegment(s string) bool {
if len(s) == 0 {
return false
}
// Most segments should be pronounceable.
return pronounceable > total/2
// Pure digits are not words (but are ok as parts of a key like "form1065").
if isAllDigits(s) {
return false
}
// Short common words.
if commonShortWords[strings.ToLower(s)] {
return true
}
// 3+ character pronounceable segments are words.
return len(s) >= 3 && segmentIsPronounceable(s)
}
func segmentIsPronounceable(s string) bool {
s = strings.ToLower(s)
// Strip trailing digits — "form1065" → "form", "line2" → "line".
core := strings.TrimRightFunc(s, unicode.IsDigit)
if core == "" {
return false // all digits
}
// If the core still has digits, it's not a word (e.g., "a1b2c3d4" → "a1b2c3d").
for _, r := range core {
if unicode.IsDigit(r) {
return false
}
}
s = strings.ToLower(core)
vowels := 0
consonantRun := 0
maxConsonantRun := 0
@ -347,70 +216,6 @@ func splitCamelCase(s string) []string {
return segments
}
func isHex(s string) bool {
for _, r := range s {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}
// allLookLikeFieldNames checks if keys look like typical struct field names:
// camelCase, snake_case, PascalCase, or short lowercase words.
func allLookLikeFieldNames(keys []string) bool {
fieldLike := 0
for _, k := range keys {
if looksLikeFieldName(k) {
fieldLike++
}
}
// If >80% look like field names, probably a struct
return fieldLike > len(keys)*4/5
}
func looksLikeFieldName(k string) bool {
if len(k) == 0 || len(k) > 40 {
return false
}
// Must start with a letter
runes := []rune(k)
if !unicode.IsLetter(runes[0]) {
return false
}
// Only letters, digits, underscores
for _, r := range runes {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
return false
}
}
return true
}
// valuesHaveSimilarShape checks if most values in the object are objects with
// similar key sets.
func valuesHaveSimilarShape(obj map[string]any) bool {
shapes := make(map[string]int)
total := 0
for _, v := range obj {
if m, ok := v.(map[string]any); ok {
shapes[shapeSignature(m)]++
total++
}
}
if total == 0 {
return false
}
// Find most common shape
maxCount := 0
for _, count := range shapes {
if count > maxCount {
maxCount = count
}
}
return maxCount > total/2
}
// inferKeyName tries to infer a meaningful key name from the map's keys.
func inferKeyName(obj map[string]any) string {
keys := sortedKeys(obj)
@ -568,6 +373,11 @@ func capitalize(s string) string {
// singularize does a naive singularization for common English plurals.
func singularize(s string) string {
// Uncountable / already-singular words ending in -s.
switch strings.ToLower(s) {
case "species", "series", "chassis", "status", "alias":
return s
}
if strings.HasSuffix(s, "ies") && len(s) > 4 {
return s[:len(s)-3] + "y"
}

View File

@ -3,6 +3,7 @@ package jsontypes
import (
"encoding/json"
"fmt"
"strings"
)
// RawPaths walks a decoded JSON value depth-first and emits flat paths with
@ -29,7 +30,21 @@ func (w *rawWalker) emit(path string) {
}
func (w *rawWalker) nextName(path string) string {
name := inferTypeName(path)
name := inferRawTypeName(path)
// If the inferred name matches a parent type in the path, prefix with
// the parent to disambiguate. E.g., a "room" field under {Room1} would
// infer "Room" — prefix it to "RoomRoom" so the reader knows it's the
// child type, not the parent.
parent := parentTypeName(path)
if parent != "" {
// Strip trailing digits from parent for comparison.
parentBase := strings.TrimRight(parent, "0123456789")
if parentBase == name {
name = parentBase + name
}
}
id := w.counter
w.counter++
return fmt.Sprintf("%s%d", name, id)
@ -216,3 +231,54 @@ func (w *rawWalker) walkCollection(prefix string, values []any) {
}
}
// inferRawTypeName produces a simple type name for raw paths.
// Collection elements get "<Name>Item" (e.g., friends[] → FriendsItem).
// Direct fields use PascalCase as-is (e.g., address → Address).
func inferRawTypeName(path string) string {
if path == "." {
return "Root"
}
// Detect collection context: path ends with [], [string], [int], etc.
isCollection := strings.HasSuffix(path, "[]") ||
(strings.Contains(path, "[") && strings.HasSuffix(path, "]"))
parts := strings.FieldsFunc(path, func(r rune) bool {
return r == '.' || r == '[' || r == ']' || r == '{' || r == '}'
})
if len(parts) == 0 {
if isCollection {
return "RootItem"
}
return "Root"
}
last := parts[len(parts)-1]
// Skip index-like segments to get the field name.
if last == "int" || last == "string" || last == "id" {
if len(parts) >= 2 {
last = parts[len(parts)-2]
} else {
return "RootItem"
}
}
name := snakeToPascal(last)
if isCollection {
name += "Item"
}
// If the name is too generic, prepend the parent.
if canonical, ok := ambiguousTypeNames[strings.ToLower(name)]; ok {
parent := parentTypeName(path)
if parent != "" {
if isCollection {
return parent + canonical + "Item"
}
return parent + canonical
}
}
return name
}

View File

@ -0,0 +1,92 @@
package jsontypes
import (
"encoding/json"
"os"
"strings"
"testing"
)
func TestRawPathsFixtures(t *testing.T) {
tests := []struct {
name string
file string
// Spot-check: paths that must appear in the output.
mustContain []string
// No struct-explosion: output should be under this many lines.
maxLines int
}{
{
name: "pokemon",
file: "testdata/pokemon-pikachu.json",
mustContain: []string{
".{Root0}.name{string}",
".{Root0}.abilities[]{AbilitiesItem",
".{Root0}.stats[]{StatsItem",
".{Root0}.types[]{TypesItem",
".{Root0}.sprites{Sprite",
},
maxLines: 250,
},
{
name: "swapi_person",
file: "testdata/swapi-luke.json",
mustContain: []string{
".{Root0}.name{string}",
".{Root0}.height{string}",
".{Root0}.films[]{string}",
},
maxLines: 30,
},
{
name: "swapi_planets",
file: "testdata/swapi-planets.json",
mustContain: []string{
".{Root0}.count{int}",
".{Root0}.results[]{ResultsItem",
"climate{string}",
"population{string}",
},
maxLines: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := os.Open(tt.file)
if err != nil {
t.Fatalf("open %s: %v", tt.file, err)
}
defer f.Close()
var data any
dec := json.NewDecoder(f)
dec.UseNumber()
if err := dec.Decode(&data); err != nil {
t.Fatalf("decode: %v", err)
}
paths := RawPaths(data)
if len(paths) > tt.maxLines {
t.Errorf("too many paths: got %d, max %d (possible struct explosion)",
len(paths), tt.maxLines)
}
for _, want := range tt.mustContain {
found := false
for _, p := range paths {
if strings.Contains(p, want) {
found = true
break
}
}
if !found {
t.Errorf("missing expected path containing %q", want)
}
}
t.Logf("%d paths from %s", len(paths), tt.file)
})
}
}

7
tools/jsontypes/testdata/README.md vendored Normal file
View File

@ -0,0 +1,7 @@
# Test Data Sources
These JSON fixtures are used for integration testing of the jsontypes package.
- **pokemon-pikachu.json** — Single Pokemon record from [PokeAPI](https://pokeapi.co/api/v2/pokemon/pikachu) (BSD 3-Clause). Pokemon is © Nintendo/The Pokemon Company.
- **swapi-luke.json** — Luke Skywalker from [SWAPI](https://swapi.dev/api/people/1/) (BSD 3-Clause). Star Wars is © Lucasfilm Ltd.
- **swapi-planets.json** — First page of planets from [SWAPI](https://swapi.dev/api/planets/) (BSD 3-Clause). Star Wars is © Lucasfilm Ltd.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"name":"Luke Skywalker","height":"172","mass":"77","hair_color":"blond","skin_color":"fair","eye_color":"blue","birth_year":"19BBY","gender":"male","homeworld":"https://swapi.dev/api/planets/1/","films":["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/","https://swapi.dev/api/films/3/","https://swapi.dev/api/films/6/"],"species":[],"vehicles":["https://swapi.dev/api/vehicles/14/","https://swapi.dev/api/vehicles/30/"],"starships":["https://swapi.dev/api/starships/12/","https://swapi.dev/api/starships/22/"],"created":"2014-12-09T13:50:51.644000Z","edited":"2014-12-20T21:17:56.891000Z","url":"https://swapi.dev/api/people/1/"}

File diff suppressed because one or more lines are too long