feat(jsontypes): tier word detection by key count for map heuristic

With ≤3 keys, one word-like key is enough to call it a struct.
With 4+ keys, a majority must be word-like — prevents a single
coincidental match (e.g., "beef" in hex keys) from misclassifying.

Also fixes short segment handling: 2-char abbreviations like "ms" in
"account_ms" are neutral rather than causing the whole key to fail
word detection.
This commit is contained in:
AJ ONeal 2026-03-07 23:21:23 -07:00
parent 439558a8ac
commit 69d757bc69
No known key found for this signature in database
2 changed files with 27 additions and 19 deletions

View File

@ -172,10 +172,10 @@ func TestHeuristicsMapDetection(t *testing.T) {
wantConf bool
}{
{"numeric keys", []string{"1", "2", "3"}, true, true},
{"uuid keys", []string{"a1b2c3d4-e5f6", "b2c3d4e5-f6a7", "c3d4e5f6-a7b8"}, true, true},
{"uuid keys", []string{"a1b2c3d4-e5f6", "b2c3d4e5-f6a7", "c3d4e5f6-a7b8"}, true, false},
{"field names", []string{"name", "age", "email"}, false, true},
{"two keys no words", []string{"ab", "cd"}, true, false},
{"hex IDs", []string{"a1b2c3d4", "e5f6a7b8", "c9d0e1f2"}, true, true},
{"hex IDs", []string{"a1b2c3d4", "e5f6a7b8", "c9d0e1f2"}, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -37,27 +37,35 @@ func looksLikeMap(obj map[string]any) (isMap bool, confident bool) {
return true, true
}
// Rule 2: Any key is composed of words → struct.
if hasWordLikeKey(keys) {
// Rule 2: Keys that look like words → struct.
// With few keys, one word is enough. With many keys, a majority must
// be words — otherwise a few coincidental matches (e.g., "beef" in hex)
// could misclassify a map.
wordCount := countWordLikeKeys(keys)
if n <= 3 {
if wordCount >= 1 {
return false, true
}
return true, false
}
if wordCount > n/2 {
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
// Rule 3: No word majority → default to map.
return true, true
}
// 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 {
// countWordLikeKeys returns how many keys look like they're composed of words
// (i.e., struct field names rather than IDs/tokens).
func countWordLikeKeys(keys []string) int {
count := 0
for _, k := range keys {
if isWordLikeKey(k) {
return true
count++
}
}
return false
return count
}
// isWordLikeKey checks whether a key is composed of word-like segments,
@ -76,11 +84,11 @@ func isWordLikeKey(k string) bool {
}
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").
// Pure digits and short segments (< 3 chars) that aren't known
// short words are neutral — skip without failing.
// This handles abbreviations like "ms", "db", "ui" in keys
// like "account_ms" or "build_db".
if isAllDigits(seg) || (len(seg) < 3 && !commonShortWords[strings.ToLower(seg)]) {
continue
}
if !isWordSegment(seg) {