golib/tools/jsontypes/heuristics.go
AJ ONeal 439558a8ac
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.
2026-06-11 16:14:38 -06:00

395 lines
10 KiB
Go

package jsontypes
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// 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
}
// Rule 1: All keys are integers → always a map.
allInts := true
for _, k := range keys {
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
allInts = false
break
}
}
if allInts {
return true, true
}
// Rule 2: Any key is composed of words → struct.
if hasWordLikeKey(keys) {
return false, true
}
// 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
}
// 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 isWordLikeKey(k) {
return true
}
}
return false
}
// 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).
//
// "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
}
hasStrongWord := false
for _, seg := range segments {
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
}
if !isWordSegment(seg) {
return false
}
if len(seg) >= 3 && segmentIsPronounceable(seg) {
hasStrongWord = true
}
}
return hasStrongWord
}
// 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
}
// 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 {
// 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
letters := 0
for _, r := range s {
if !unicode.IsLetter(r) {
consonantRun = 0
continue
}
letters++
if isVowel(r) {
vowels++
consonantRun = 0
} else {
consonantRun++
if consonantRun > maxConsonantRun {
maxConsonantRun = consonantRun
}
}
}
if letters < 2 {
return false
}
ratio := float64(vowels) / float64(letters)
// English words typically have 30-50% vowels.
// Allow a wide band (15-80%) to cover abbreviations and acronyms.
if ratio < 0.15 || ratio > 0.80 {
return false
}
// No natural word has 5+ consonants in a row.
if maxConsonantRun > 4 {
return false
}
return true
}
func isVowel(r rune) bool {
switch r {
case 'a', 'e', 'i', 'o', 'u', 'y':
return true
}
return false
}
func isAllDigits(s string) bool {
for _, r := range s {
if !unicode.IsDigit(r) {
return false
}
}
return len(s) > 0
}
// splitWordSegments breaks a string into word-like segments by splitting on
// underscores, hyphens, and camelCase boundaries.
func splitWordSegments(s string) []string {
// First split on underscores and hyphens.
var parts []string
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
return r == '_' || r == '-'
}) {
// Then split on camelCase boundaries.
parts = append(parts, splitCamelCase(part)...)
}
return parts
}
func splitCamelCase(s string) []string {
var segments []string
start := 0
runes := []rune(s)
for i := 1; i < len(runes); i++ {
if unicode.IsUpper(runes[i]) && (i > start) {
segments = append(segments, string(runes[start:i]))
start = i
}
}
segments = append(segments, string(runes[start:]))
return segments
}
// inferKeyName tries to infer a meaningful key name from the map's keys.
func inferKeyName(obj map[string]any) string {
keys := sortedKeys(obj)
if len(keys) == 0 {
return "string"
}
// All numeric?
allNum := true
for _, k := range keys {
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
allNum = false
break
}
}
if allNum {
return "int"
}
// Check if all values are objects with a common field that matches the
// key (e.g., keys are "abc123" and objects have an "id" field with "abc123").
// This suggests the key name is "id".
for _, fieldName := range []string{"id", "ID", "Id", "_id"} {
match := true
for k, v := range obj {
if m, ok := v.(map[string]any); ok {
if val, exists := m[fieldName]; exists {
if fmt.Sprintf("%v", val) == k {
continue
}
}
}
match = false
break
}
if match && len(obj) > 0 {
return fieldName
}
}
return "string"
}
// ambiguousTypeNames maps lowercase inferred names to their canonical form.
// When one of these is inferred, the parent type name is prepended and the
// canonical form is used (e.g., "json" in any casing → ParentJSON).
var ambiguousTypeNames = map[string]string{
"json": "JSON",
"data": "Data",
"item": "Item",
"value": "Value",
"result": "Result",
}
// inferTypeName tries to guess a struct name from the path context.
func inferTypeName(path string) string {
// Root path → "Root"
if path == "." {
return "Root"
}
// Root-level collection items (no parent type yet)
// e.g., ".[]", ".[string]", ".[int]"
if !strings.Contains(path, "{") {
name := inferTypeNameFromSegments(path)
if name == "" {
return "RootItem"
}
return name
}
return inferTypeNameFromSegments(path)
}
func inferTypeNameFromSegments(path string) string {
// Extract the last meaningful segment from the path
// e.g., ".friends[int]" → "Friend", ".{Person}.address" → "Address"
parts := strings.FieldsFunc(path, func(r rune) bool {
return r == '.' || r == '[' || r == ']' || r == '{' || r == '}'
})
if len(parts) == 0 {
return ""
}
last := parts[len(parts)-1]
// Skip index-like segments
if last == "int" || last == "string" || last == "id" {
if len(parts) >= 2 {
last = parts[len(parts)-2]
} else {
return ""
}
}
// Strip common suffixes like _id, _key, Id
last = strings.TrimSuffix(last, "_id")
last = strings.TrimSuffix(last, "_key")
last = strings.TrimSuffix(last, "Id")
last = strings.TrimSuffix(last, "Key")
if last == "" {
return ""
}
name := singularize(snakeToPascal(last))
// If the inferred name is too generic, use canonical form and prepend parent
if canonical, ok := ambiguousTypeNames[strings.ToLower(name)]; ok {
parent := parentTypeName(path)
if parent != "" {
return parent + canonical
}
return canonical
}
return name
}
// isUbiquitousField returns true if a field name is so common across all
// domains (databases, APIs, languages) that sharing it doesn't imply the
// objects are the same type. These are excluded when deciding whether to
// default to "same" or "different" types.
func isUbiquitousField(name string) bool {
// Exact matches
switch name {
case "id", "ID", "Id", "_id",
"name", "Name",
"type", "Type", "_type",
"kind", "Kind",
"slug", "Slug",
"label", "Label",
"title", "Title",
"description", "Description":
return true
}
// Suffix patterns: *_at, *_on, *At, *On (timestamps/dates)
if strings.HasSuffix(name, "_at") || strings.HasSuffix(name, "_on") ||
strings.HasSuffix(name, "At") || strings.HasSuffix(name, "On") {
return true
}
return false
}
// snakeToPascal converts snake_case or camelCase to PascalCase.
func snakeToPascal(s string) string {
parts := strings.Split(s, "_")
for i, p := range parts {
parts[i] = capitalize(p)
}
return strings.Join(parts, "")
}
func capitalize(s string) string {
if len(s) == 0 {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// 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"
}
if strings.HasSuffix(s, "ses") || strings.HasSuffix(s, "xes") || strings.HasSuffix(s, "zes") {
return s[:len(s)-2]
}
if strings.HasSuffix(s, "ss") || strings.HasSuffix(s, "us") || strings.HasSuffix(s, "is") {
return s // not plural
}
if strings.HasSuffix(s, "s") && len(s) > 3 {
return s[:len(s)-1]
}
return s
}