ref(jsontypes): replace Prompter with Resolver callback pattern

Separate library from CLI concerns:
- Add Resolver callback type with Decision/Response structs for all
  interactive decisions (map/struct, type name, tuple/list, shape
  unification, shape naming, name collision)
- Move terminal I/O (Prompter) from library to cmd/jsonpaths
- Add public API: New(), ParseFormat(), Generate(), AutoGenerate()
- Add Format type with aliases (ts, py, json-paths, etc.)
- Fix godoc comments to match exported function names
- Update tests to use scriptedResolver instead of Prompter internals
- Update doc.go and README with current API
This commit is contained in:
AJ ONeal 2026-03-07 21:38:43 -07:00
parent 40106d14cd
commit cdadf91459
No known key found for this signature in database
22 changed files with 848 additions and 515 deletions

View File

@ -7,17 +7,34 @@ import (
"strings" "strings"
) )
// AnalyzerConfig configures an Analyzer.
type AnalyzerConfig struct {
// Resolver handles interactive decisions during analysis.
// If nil, heuristic defaults are used (fully autonomous).
//
// When set, the resolver is called only when the analyzer is
// genuinely unsure: ambiguous map/struct, multiple object shapes,
// tuple candidates, and unresolvable name collisions. Confident
// heuristic decisions are made without calling the resolver.
Resolver Resolver
// AskTypes prompts for every type name, even when heuristics
// are confident. Only meaningful when Resolver is set.
AskTypes bool
}
// Analyzer holds state for a single JSON analysis pass. Create a new
// Analyzer for each JSON document; do not reuse across documents.
type Analyzer struct { type Analyzer struct {
Prompter *Prompter resolver Resolver
anonymous bool autonomous bool
askTypes bool askTypes bool
typeCounter int typeCounter int
// knownTypes maps shape signature → type name knownTypes map[string]*structType
knownTypes map[string]*structType
// typesByName maps type name → structType for collision detection
typesByName map[string]*structType typesByName map[string]*structType
// pendingTypeName is set by the combined map/struct+name prompt // pendingTypeName is set by decideMapOrStruct and consumed by
// and consumed by decideTypeName to avoid double-prompting // decideTypeName to avoid double-prompting.
pendingTypeName string pendingTypeName string
} }
@ -27,30 +44,27 @@ type structType struct {
} }
type shapeGroup struct { type shapeGroup struct {
sig string
fields []string fields []string
members []map[string]any members []map[string]any
} }
func NewAnalyzer(inputIsStdin, anonymous, askTypes bool) (*Analyzer, error) { // New creates an Analyzer with the given configuration.
p, err := NewPrompter(inputIsStdin, anonymous) func New(cfg AnalyzerConfig) *Analyzer {
if err != nil { r := cfg.Resolver
return nil, err autonomous := r == nil
if r == nil {
r = defaultResolver
} }
return &Analyzer{ return &Analyzer{
Prompter: p, resolver: r,
anonymous: anonymous, autonomous: autonomous,
askTypes: askTypes, askTypes: cfg.AskTypes,
knownTypes: make(map[string]*structType), knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType), typesByName: make(map[string]*structType),
}, nil }
} }
func (a *Analyzer) Close() { // Analyze traverses a JSON value depth-first and returns annotated flat paths.
a.Prompter.Close()
}
// analyze traverses a JSON value depth-first and returns annotated flat paths.
func (a *Analyzer) Analyze(path string, val any) []string { func (a *Analyzer) Analyze(path string, val any) []string {
switch v := val.(type) { switch v := val.(type) {
case nil: case nil:
@ -86,7 +100,7 @@ func (a *Analyzer) analyzeObject(path string, obj map[string]any) []string {
} }
func (a *Analyzer) analyzeAsMap(path string, obj map[string]any) []string { func (a *Analyzer) analyzeAsMap(path string, obj map[string]any) []string {
keyName := a.decideKeyName(path, obj) keyName := inferKeyName(obj)
// Collect all values and group by shape for type unification // Collect all values and group by shape for type unification
values := make([]any, 0, len(obj)) values := make([]any, 0, len(obj))
@ -146,7 +160,7 @@ func (a *Analyzer) analyzeArray(path string, arr []any) []string {
} }
// Check for tuple (short array of mixed types) // Check for tuple (short array of mixed types)
if a.isTupleCandidate(arr) { if isTupleCandidate(arr) {
isTuple := a.decideTupleOrList(path, arr) isTuple := a.decideTupleOrList(path, arr)
if isTuple { if isTuple {
return a.analyzeAsTuple(path, arr) return a.analyzeAsTuple(path, arr)
@ -245,7 +259,6 @@ func (a *Analyzer) unifyObjects(path string, objects []map[string]any) []string
g.members = append(g.members, obj) g.members = append(g.members, obj)
} else { } else {
g := &shapeGroup{ g := &shapeGroup{
sig: sig,
fields: sortedKeys(obj), fields: sortedKeys(obj),
members: []map[string]any{obj}, members: []map[string]any{obj},
} }
@ -259,8 +272,8 @@ func (a *Analyzer) unifyObjects(path string, objects []map[string]any) []string
return a.analyzeAsStructMulti(path, objects) return a.analyzeAsStructMulti(path, objects)
} }
// Multiple shapes — in anonymous mode default to same type // Multiple shapes — in autonomous mode default to same type
if a.anonymous { if a.autonomous {
return a.analyzeAsStructMulti(path, objects) return a.analyzeAsStructMulti(path, objects)
} }
return a.promptTypeUnification(path, groups, groupOrder) return a.promptTypeUnification(path, groups, groupOrder)
@ -302,66 +315,26 @@ func (a *Analyzer) tryAnalyzeAsMaps(path string, objects []map[string]any) []str
return a.analyzeAsMap(path, combined) return a.analyzeAsMap(path, combined)
} }
// promptTypeUnification presents shape groups to the user and asks if they // promptTypeUnification presents shape groups and asks if they are the same
// are the same type (with optional fields) or different types. // type (with optional fields) or different types.
func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string { func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string {
const maxFields = 8
// Compute shared and unique fields across all shapes // Compute shared and unique fields across all shapes
shared, uniquePerShape := shapeFieldBreakdown(groups, groupOrder) shared, uniquePerShape := shapeFieldBreakdown(groups, groupOrder)
totalInstances := 0
for _, sig := range groupOrder {
totalInstances += len(groups[sig].members)
}
fmt.Fprintf(a.Prompter.output, "\nAt %s — %d shapes (%d instances):\n", // Build shape summaries for the resolver
shortPath(path), len(groupOrder), totalInstances) shapes := make([]ShapeSummary, len(groupOrder))
for i, sig := range groupOrder {
// Show shared fields
if len(shared) > 0 {
preview := shared
if len(preview) > maxFields {
preview = preview[:maxFields]
}
fmt.Fprintf(a.Prompter.output, " shared fields (%d): %s", len(shared), strings.Join(preview, ", "))
if len(shared) > maxFields {
fmt.Fprintf(a.Prompter.output, ", ...")
}
fmt.Fprintln(a.Prompter.output)
} else {
fmt.Fprintf(a.Prompter.output, " no shared fields\n")
}
// Show unique fields per shape (truncated)
shownShapes := groupOrder
if len(shownShapes) > 5 {
shownShapes = shownShapes[:5]
}
for i, sig := range shownShapes {
g := groups[sig] g := groups[sig]
unique := uniquePerShape[sig] shapes[i] = ShapeSummary{
if len(unique) == 0 { Index: i,
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): no unique fields\n", i+1, len(g.members)) Instances: len(g.members),
continue Fields: g.fields,
UniqueFields: uniquePerShape[sig],
} }
preview := unique
if len(preview) > maxFields {
preview = preview[:maxFields]
}
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): +%d unique: %s",
i+1, len(g.members), len(unique), strings.Join(preview, ", "))
if len(unique) > maxFields {
fmt.Fprintf(a.Prompter.output, ", ...")
}
fmt.Fprintln(a.Prompter.output)
}
if len(groupOrder) > 5 {
fmt.Fprintf(a.Prompter.output, " ... and %d more shapes\n", len(groupOrder)-5)
} }
// Decide default: if unique fields heavily outnumber meaningful shared // Decide default: if unique fields heavily outnumber meaningful shared
// fields, default to "different". Ubiquitous fields (id, name, *_at, etc.) // fields, default to "different".
// don't count as meaningful shared fields.
meaningfulShared := 0 meaningfulShared := 0
for _, f := range shared { for _, f := range shared {
if !isUbiquitousField(f) { if !isUbiquitousField(f) {
@ -372,34 +345,27 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr
for _, sig := range groupOrder { for _, sig := range groupOrder {
totalUnique += len(uniquePerShape[sig]) totalUnique += len(uniquePerShape[sig])
} }
defaultChoice := "s" defaultIsNewType := totalUnique >= 2*meaningfulShared
if totalUnique >= 2*meaningfulShared {
defaultChoice = "d" d := &Decision{
Kind: DecideUnifyShapes,
Path: shortPath(path),
Default: Response{IsNewType: defaultIsNewType},
Shapes: shapes,
SharedFields: shared,
} }
// Combined prompt: same/different/show full list // Pre-collect all instances for the common "same type" path.
var choice string var all []map[string]any
for { for _, sig := range groupOrder {
choice = a.Prompter.ask( all = append(all, groups[sig].members...)
"[s]ame type? [d]ifferent? show [f]ull list?",
defaultChoice, []string{"s", "d", "f"},
)
if choice != "f" {
break
}
for i, sig := range groupOrder {
g := groups[sig]
fmt.Fprintf(a.Prompter.output, " Shape %d (%d instances): %s\n",
i+1, len(g.members), strings.Join(g.fields, ", "))
}
} }
if choice == "s" { if err := a.resolver(d); err != nil {
// Same type — analyze with all instances for field unification return a.analyzeAsStructMulti(path, all)
var all []map[string]any }
for _, sig := range groupOrder {
all = append(all, groups[sig].members...) if !d.Response.IsNewType {
}
return a.analyzeAsStructMulti(path, all) return a.analyzeAsStructMulti(path, all)
} }
@ -412,20 +378,29 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr
a.typeCounter++ a.typeCounter++
inferred = fmt.Sprintf("Struct%d", a.typeCounter) inferred = fmt.Sprintf("Struct%d", a.typeCounter)
} }
// Pre-resolve collision so the suggested name is valid
merged := mergeObjects(g.members) merged := mergeObjects(g.members)
newFields := fieldSet(merged) newFields := fieldSet(merged)
shapeSig := shapeSignature(merged) shapeSig := shapeSignature(merged)
inferred = a.preResolveCollision(path, inferred, newFields, shapeSig) inferred = a.preResolveCollision(path, inferred, newFields)
fmt.Fprintf(a.Prompter.output, " Shape %d (%d instances): %s\n", sd := &Decision{
i+1, len(g.members), strings.Join(g.fields, ", ")) Kind: DecideShapeName,
name := a.Prompter.askTypeName( Path: shortPath(path),
fmt.Sprintf(" Name for shape %d?", i+1), inferred) Default: Response{Name: inferred},
names[i] = name Fields: objectToFieldSummaries(merged),
ShapeIndex: i,
}
if err := a.resolver(sd); err != nil {
names[i] = inferred
} else {
names[i] = sd.Response.Name
if names[i] == "" {
names[i] = inferred
}
}
// Register early so subsequent shapes see this name as taken // Register early so subsequent shapes see this name as taken
a.registerType(shapeSig, name, newFields) a.registerType(shapeSig, names[i], newFields)
} }
// Now analyze each group with its pre-assigned name // Now analyze each group with its pre-assigned name
@ -489,7 +464,7 @@ func sortedFieldCount(m map[string]int) []string {
// isTupleCandidate returns true if the array might be a tuple: // isTupleCandidate returns true if the array might be a tuple:
// short (2-5 elements) with mixed types. // short (2-5 elements) with mixed types.
func (a *Analyzer) isTupleCandidate(arr []any) bool { func isTupleCandidate(arr []any) bool {
if len(arr) < 2 || len(arr) > 5 { if len(arr) < 2 || len(arr) > 5 {
return false return false
} }

View File

@ -1,28 +1,39 @@
package jsontypes package jsontypes
import ( import (
"bufio"
"encoding/json" "encoding/json"
"io"
"os"
"sort" "sort"
"strings" "strings"
"testing" "testing"
) )
// testAnalyzer creates an analyzer in anonymous mode (no prompts). // testAnalyzer creates an analyzer in autonomous mode (no resolver).
func testAnalyzer(t *testing.T) *Analyzer { func testAnalyzer(t *testing.T) *Analyzer {
t.Helper() t.Helper()
a := &Analyzer{ return New(AnalyzerConfig{})
Prompter: &Prompter{ }
reader: nil,
output: os.Stderr, // scriptedResolver creates a Resolver that returns responses in order.
}, // After all responses are consumed, it accepts defaults.
anonymous: true, func scriptedResolver(responses ...Response) Resolver {
knownTypes: make(map[string]*structType), i := 0
typesByName: make(map[string]*structType), return func(d *Decision) error {
if i >= len(responses) {
d.Response = d.Default
return nil
}
d.Response = responses[i]
i++
return nil
} }
return a }
// testInteractiveAnalyzer creates an analyzer with scripted responses.
func testInteractiveAnalyzer(t *testing.T, responses ...Response) *Analyzer {
t.Helper()
return New(AnalyzerConfig{
Resolver: scriptedResolver(responses...),
})
} }
func sortPaths(paths []string) []string { func sortPaths(paths []string) []string {
@ -36,14 +47,13 @@ func TestAnalyzePrimitive(t *testing.T) {
a := testAnalyzer(t) a := testAnalyzer(t)
tests := []struct { tests := []struct {
name string name string
json string
want string want string
}{ }{
{"null", "", ".{null}"}, {"null", ".{null}"},
{"bool", "", ".{bool}"}, {"bool", ".{bool}"},
{"int", "", ".{int}"}, {"int", ".{int}"},
{"float", "", ".{float}"}, {"float", ".{float}"},
{"string", "", ".{string}"}, {"string", ".{string}"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -427,15 +437,19 @@ func TestAnalyzeFullSample(t *testing.T) {
assertPaths(t, paths, want) assertPaths(t, paths, want)
} }
// TestDifferentTypesPromptsForNames verifies that when the user chooses // TestDifferentTypesViaResolver verifies that when the resolver returns
// "different" for multiple shapes at the same path: // IsNewType=true for multiple shapes at the same path:
// 1. They are prompted to name each shape group // 1. Shape names are requested via DecideShapeName
// 2. All names are collected BEFORE recursing into children // 2. The named types appear in the final output
// 3. The named types appear in the final output func TestDifferentTypesViaResolver(t *testing.T) {
func TestDifferentTypesPromptsForNames(t *testing.T) { a := New(AnalyzerConfig{
// Simulate: a Room has items[] containing two distinct shapes, each with Resolver: scriptedResolver(
// a nested "meta" object. Names should be asked for both shapes before Response{IsNewType: true}, // different types for shapes
// the meta objects are analyzed. Response{Name: "FileField"}, // name for shape 1
Response{Name: "FeatureField"}, // name for shape 2
),
})
arr := []any{ arr := []any{
// Shape 1: has "filename" and "is_required" // Shape 1: has "filename" and "is_required"
map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true, map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true,
@ -449,19 +463,8 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
"meta": map[string]any{"version": jsonNum("2")}}, "meta": map[string]any{"version": jsonNum("2")}},
} }
var output strings.Builder
a := &Analyzer{
Prompter: &Prompter{
reader: bufio.NewReader(strings.NewReader("")),
output: &output,
priorAnswers: []string{"d", "FileField", "FeatureField"},
},
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
}
paths := sortPaths(a.Analyze(".{Room}.items[]", arr)) paths := sortPaths(a.Analyze(".{Room}.items[]", arr))
// Verify both named types appear in the paths
hasFileField := false hasFileField := false
hasFeatureField := false hasFeatureField := false
for _, p := range paths { for _, p := range paths {
@ -479,17 +482,6 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
t.Errorf("expected {FeatureField} type in paths:\n %s", strings.Join(paths, "\n ")) t.Errorf("expected {FeatureField} type in paths:\n %s", strings.Join(paths, "\n "))
} }
// Verify that both "Name for shape" prompts appear before any deeper prompts
out := output.String()
name1Idx := strings.Index(out, "Name for shape 1?")
name2Idx := strings.Index(out, "Name for shape 2?")
if name1Idx < 0 || name2Idx < 0 {
t.Fatalf("expected both shape name prompts in output:\n%s", out)
}
if name1Idx > name2Idx {
t.Errorf("shape 1 name prompt should appear before shape 2")
}
// Verify the formatted output includes these types // Verify the formatted output includes these types
formatted := FormatPaths(paths) formatted := FormatPaths(paths)
foundFileField := false foundFileField := false
@ -510,109 +502,64 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
} }
} }
// TestCombinedPromptShowsTypeName verifies the default-mode prompt shows // TestDecideMapOrStructDefault verifies that the library sends
// [Root/m] (inferred name + map option), not [s/m] or [S/m]. // the inferred type name as the default in DecideMapOrStruct decisions.
func TestCombinedPromptShowsTypeName(t *testing.T) { func TestDecideMapOrStructDefault(t *testing.T) {
var output strings.Builder var captured *Decision
a := &Analyzer{ a := New(AnalyzerConfig{
Prompter: &Prompter{ Resolver: func(d *Decision) error {
reader: bufio.NewReader(strings.NewReader("")), if d.Kind == DecideMapOrStruct && captured == nil {
output: &output, cp := *d
priorAnswers: []string{"Root"}, // accept default captured = &cp
}
d.Response = d.Default
return nil
}, },
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
}
obj := map[string]any{
"errors": []any{},
"rooms": []any{map[string]any{"name": "foo"}},
}
a.Analyze(".", obj)
out := output.String()
if !strings.Contains(out, "[Root/m]") {
t.Errorf("expected prompt to contain [Root/m], got output:\n%s", out)
}
}
// TestCombinedPromptIgnoresOldPriorAnswer verifies that prior answers like
// "s" from old answer files don't corrupt the prompt default.
func TestCombinedPromptIgnoresOldPriorAnswer(t *testing.T) {
var output strings.Builder
a := &Analyzer{
Prompter: &Prompter{
reader: bufio.NewReader(strings.NewReader("")),
output: &output,
priorAnswers: []string{"s"}, // old-style answer
},
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
}
obj := map[string]any{
"errors": []any{},
"rooms": []any{map[string]any{"name": "foo"}},
}
a.Analyze(".", obj)
out := output.String()
if strings.Contains(out, "[s/m]") {
t.Errorf("old prior answer 's' should not appear in prompt, got output:\n%s", out)
}
if !strings.Contains(out, "[Root/m]") {
t.Errorf("expected prompt to contain [Root/m], got output:\n%s", out)
}
}
// TestOldAnswerFileDoesNotDesync verifies that an old-format answer "s" for
// map/struct is consumed (not skipped), so subsequent answers stay in sync.
func TestOldAnswerFileDoesNotDesync(t *testing.T) {
// Prior answers: "s" (old struct answer for root), then "s" (same type
// for a shape unification prompt). The "s" at position 0 should be consumed
// by askMapOrName (treated as "accept default"), and "s" at position 1
// should be consumed by the ask() for same/different.
a := testInteractiveAnalyzer(t, []string{
"s", // old-format: accept struct default → Root
"s", // same type for shapes
}) })
// An array with two shapes that will trigger unification prompt obj := map[string]any{
arr := []any{ "errors": []any{},
map[string]any{"name": "Alice", "x": jsonNum("1")}, "rooms": []any{map[string]any{"name": "foo"}},
map[string]any{"name": "Bob", "y": jsonNum("2")},
} }
obj := map[string]any{"items": arr} a.Analyze(".", obj)
paths := sortPaths(a.Analyze(".", obj))
// Should have Root type (from "s" → accept default) and Item type if captured == nil {
// unified as same type (from "s" → same) t.Fatal("expected DecideMapOrStruct decision")
hasRoot := false
for _, p := range paths {
if strings.Contains(p, "{Root}") {
hasRoot = true
break
}
} }
if !hasRoot { if captured.Default.Name != "Root" {
t.Errorf("expected {Root} type (old 's' should accept default), got:\n %s", t.Errorf("expected default name %q, got %q", "Root", captured.Default.Name)
strings.Join(paths, "\n "))
} }
} }
// TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share // TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share
// only ubiquitous fields (slug, name, etc.) and have many unique fields, the // only ubiquitous fields (slug, name, etc.) and have many unique fields, the
// prompt defaults to "d" (different) instead of "s" (same). // default response suggests different types (IsNewType=true).
func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) { func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
// Two shapes sharing only "slug" (ubiquitous) with 2+ unique fields each. var unifyDecision *Decision
// With no prior answer for same/different, the default should be "d". a := New(AnalyzerConfig{
// Then we need type names for each shape. Resolver: func(d *Decision) error {
// Shape ordering is insertion order: shape 1 = filename,is_required,slug; shape 2 = archived,feature,slug if d.Kind == DecideUnifyShapes && unifyDecision == nil {
a := testInteractiveAnalyzer(t, []string{ cp := *d
"Root", // root object has 1 key → not confident, prompts for struct/map unifyDecision = &cp
"d", // accept default (should be "d" because unique >> meaningful shared) }
"FileField", // name for shape 1 (filename, is_required, slug) // For shape unification, accept the default; for other decisions
"FeatureField", // name for shape 2 (archived, feature, slug) // provide the expected responses.
switch d.Kind {
case DecideMapOrStruct:
d.Response = Response{Name: "Root"}
case DecideUnifyShapes:
d.Response = d.Default
case DecideShapeName:
if d.ShapeIndex == 0 {
d.Response = Response{Name: "FileField"}
} else {
d.Response = Response{Name: "FeatureField"}
}
default:
d.Response = d.Default
}
return nil
},
}) })
arr := []any{ arr := []any{
@ -622,6 +569,13 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
obj := map[string]any{"items": arr} obj := map[string]any{"items": arr}
paths := sortPaths(a.Analyze(".", obj)) paths := sortPaths(a.Analyze(".", obj))
if unifyDecision == nil {
t.Fatal("expected DecideUnifyShapes decision")
}
if !unifyDecision.Default.IsNewType {
t.Error("expected default IsNewType=true when unique fields dominate")
}
// Should have both FileField and FeatureField as separate types // Should have both FileField and FeatureField as separate types
hasFile := false hasFile := false
hasFeature := false hasFeature := false
@ -640,14 +594,23 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
} }
// TestDefaultSameWhenMeaningfulFieldsShared verifies that when shapes share // TestDefaultSameWhenMeaningfulFieldsShared verifies that when shapes share
// many non-ubiquitous fields, the prompt defaults to "s" (same). // many non-ubiquitous fields, the default response suggests same type.
func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) { func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
// Two shapes sharing "email", "phone", "address" (non-ubiquitous) with var unifyDecision *Decision
// only 1 unique field each. unique (2) < 2 * meaningful shared (3), so a := New(AnalyzerConfig{
// default should be "s". Resolver: func(d *Decision) error {
a := testInteractiveAnalyzer(t, []string{ if d.Kind == DecideUnifyShapes && unifyDecision == nil {
"Root", // root object has 1 key → not confident, prompts for struct/map cp := *d
"s", // accept default (should be "s") unifyDecision = &cp
}
switch d.Kind {
case DecideMapOrStruct:
d.Response = Response{Name: "Root"}
default:
d.Response = d.Default
}
return nil
},
}) })
arr := []any{ arr := []any{
@ -657,7 +620,14 @@ func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
obj := map[string]any{"people": arr} obj := map[string]any{"people": arr}
paths := sortPaths(a.Analyze(".", obj)) paths := sortPaths(a.Analyze(".", obj))
// Should be unified as one type (People → singular People) with optional fields if unifyDecision == nil {
t.Fatal("expected DecideUnifyShapes decision")
}
if unifyDecision.Default.IsNewType {
t.Error("expected default IsNewType=false when meaningful fields are shared")
}
// Should be unified as one type with optional fields
typeCount := 0 typeCount := 0
for _, p := range paths { for _, p := range paths {
if strings.Contains(p, "{People}") { if strings.Contains(p, "{People}") {
@ -700,21 +670,6 @@ func TestIsUbiquitousField(t *testing.T) {
} }
} }
// testInteractiveAnalyzer creates an analyzer with scripted answers (not anonymous).
func testInteractiveAnalyzer(t *testing.T, answers []string) *Analyzer {
t.Helper()
a := &Analyzer{
Prompter: &Prompter{
reader: bufio.NewReader(strings.NewReader("")),
output: io.Discard,
priorAnswers: answers,
},
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
}
return a
}
// helpers // helpers
func jsonNum(s string) json.Number { func jsonNum(s string) json.Number {

117
tools/jsontypes/auto.go Normal file
View File

@ -0,0 +1,117 @@
package jsontypes
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// Format identifies a target output format for type generation.
type Format string
const (
FormatGo Format = "go"
FormatTypeScript Format = "typescript"
FormatJSDoc Format = "jsdoc"
FormatZod Format = "zod"
FormatPython Format = "python"
FormatSQL Format = "sql"
FormatJSONSchema Format = "jsonschema"
FormatTypedef Format = "typedef"
FormatFlatPaths Format = "paths"
)
// Options configures AutoGenerate behavior.
type Options struct {
// Format selects the output format (default: FormatFlatPaths).
Format Format
// Resolver handles interactive decisions during analysis.
// If nil, heuristic defaults are used (fully autonomous).
Resolver Resolver
// AskTypes prompts for every type name, even when heuristics
// are confident. Only meaningful when Resolver is set.
AskTypes bool
}
// ParseFormat normalizes a format string, accepting common aliases.
// Returns an error for unrecognized formats.
func ParseFormat(s string) (Format, error) {
if f, ok := formatAliases[s]; ok {
return f, nil
}
return "", fmt.Errorf("unknown format: %q (use: paths, go, typescript, jsdoc, zod, python, sql, jsonschema, typedef)", s)
}
var formatAliases = map[string]Format{
"": FormatFlatPaths,
"paths": FormatFlatPaths,
"json-paths": FormatFlatPaths,
"go": FormatGo,
"typescript": FormatTypeScript,
"ts": FormatTypeScript,
"jsdoc": FormatJSDoc,
"zod": FormatZod,
"python": FormatPython,
"py": FormatPython,
"sql": FormatSQL,
"jsonschema": FormatJSONSchema,
"json-schema": FormatJSONSchema,
"typedef": FormatTypedef,
"json-typedef": FormatTypedef,
}
// Generate renders formatted paths into the given output format.
// Use FormatFlatPaths to get the intermediate path notation.
func Generate(format Format, paths []string) (string, error) {
if format == FormatFlatPaths {
return strings.Join(paths, "\n") + "\n", nil
}
gen, ok := generators[format]
if !ok {
return "", fmt.Errorf("unknown format: %q", format)
}
return gen(paths), nil
}
// AutoGenerate parses JSON from raw bytes and generates type definitions.
func AutoGenerate(data []byte, opts Options) (string, error) {
var v any
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return "", fmt.Errorf("invalid JSON: %w", err)
}
return AutoGenerateFromAny(v, opts)
}
// AutoGenerateFromString parses a JSON string and generates type definitions.
func AutoGenerateFromString(s string, opts Options) (string, error) {
return AutoGenerate([]byte(s), opts)
}
// AutoGenerateFromAny generates type definitions from an already-decoded JSON
// value. The value must have been decoded with json.UseNumber() so that
// integers and floats are distinguishable.
func AutoGenerateFromAny(v any, opts Options) (string, error) {
a := New(AnalyzerConfig{Resolver: opts.Resolver, AskTypes: opts.AskTypes})
paths := FormatPaths(a.Analyze(".", v))
format := opts.Format
if format == "" {
format = FormatFlatPaths
}
return Generate(format, paths)
}
var generators = map[Format]func([]string) string{
FormatGo: GenerateGoStructs,
FormatTypeScript: GenerateTypeScript,
FormatJSDoc: GenerateJSDoc,
FormatZod: GenerateZod,
FormatPython: GeneratePython,
FormatSQL: GenerateSQL,
FormatJSONSchema: GenerateJSONSchema,
FormatTypedef: GenerateTypedef,
}

View File

@ -178,14 +178,19 @@ The core logic is available as a Go package:
```go ```go
import "github.com/therootcompany/golib/tools/jsontypes" import "github.com/therootcompany/golib/tools/jsontypes"
a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode // One-shot: parse JSON and generate code in one call
defer a.Close() out, err := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{
Format: jsontypes.FormatTypeScript,
})
// Or step by step:
a := jsontypes.New(jsontypes.AnalyzerConfig{})
var data any var data any
// ... json.Decode with UseNumber() ... // ... json.Decode with UseNumber() ...
paths := jsontypes.FormatPaths(a.Analyze(".", data)) paths := jsontypes.FormatPaths(a.Analyze(".", data))
fmt.Print(jsontypes.GenerateTypeScript(paths)) out, err = jsontypes.Generate(jsontypes.FormatTypeScript, paths)
``` ```
See the [package documentation](https://pkg.go.dev/github.com/therootcompany/golib/tools/jsontypes) See the [package documentation](https://pkg.go.dev/github.com/therootcompany/golib/tools/jsontypes)

View File

@ -2,9 +2,11 @@ package main
import ( import (
"bufio" "bufio"
"bytes"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -92,6 +94,12 @@ func main() {
flag.Parse() flag.Parse()
outFormat, err := jsontypes.ParseFormat(*format)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
var input io.Reader var input io.Reader
var baseName string // base filename for .paths and .answers files var baseName string // base filename for .paths and .answers files
inputIsStdin := true inputIsStdin := true
@ -158,46 +166,34 @@ func main() {
os.Exit(1) os.Exit(1)
} }
a, err := jsontypes.NewAnalyzer(inputIsStdin, *anonymous, *askTypes) var resolver jsontypes.Resolver
if err != nil { var pr *prompter
fmt.Fprintf(os.Stderr, "error: %v\n", err) if !*anonymous {
os.Exit(1) pr, err = newPrompter(inputIsStdin)
} if err != nil {
defer a.Close() fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
// Load prior answers if available }
if baseName != "" && !*anonymous { defer pr.close()
a.Prompter.LoadAnswers(baseName + ".answers") if baseName != "" {
pr.loadAnswers(baseName + ".answers")
}
resolver = newCLIResolver(pr)
} }
a := jsontypes.New(jsontypes.AnalyzerConfig{
Resolver: resolver,
AskTypes: *askTypes,
})
rawPaths := a.Analyze(".", data) rawPaths := a.Analyze(".", data)
formatted := jsontypes.FormatPaths(rawPaths) formatted := jsontypes.FormatPaths(rawPaths)
switch *format { out, err := jsontypes.Generate(outFormat, formatted)
case "go": if err != nil {
fmt.Print(jsontypes.GenerateGoStructs(formatted)) fmt.Fprintf(os.Stderr, "error: %v\n", err)
case "json-typedef":
fmt.Print(jsontypes.GenerateTypedef(formatted))
case "json-schema":
fmt.Print(jsontypes.GenerateJSONSchema(formatted))
case "typescript", "ts":
fmt.Print(jsontypes.GenerateTypeScript(formatted))
case "jsdoc":
fmt.Print(jsontypes.GenerateJSDoc(formatted))
case "zod":
fmt.Print(jsontypes.GenerateZod(formatted))
case "python", "py":
fmt.Print(jsontypes.GeneratePython(formatted))
case "sql":
fmt.Print(jsontypes.GenerateSQL(formatted))
case "json-paths", "paths", "":
for _, p := range formatted {
fmt.Println(p)
}
default:
fmt.Fprintf(os.Stderr, "error: unknown format %q (use: json-paths, go, json-schema, json-typedef, typescript, jsdoc, zod, python, sql)\n", *format)
os.Exit(1) os.Exit(1)
} }
fmt.Print(out)
// Save outputs // Save outputs
if baseName != "" { if baseName != "" {
@ -206,9 +202,9 @@ func main() {
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err) fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err)
} }
if !*anonymous { if !*anonymous && pr != nil {
answersFile := baseName + ".answers" answersFile := baseName + ".answers"
if err := a.Prompter.SaveAnswers(answersFile); err != nil { if err := pr.saveAnswers(answersFile); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", answersFile, err) fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", answersFile, err)
} }
} }
@ -291,8 +287,9 @@ func isSensitiveParam(name string) bool {
} }
func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) { func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) {
path := slugify(rawURL)
if !noCache { if !noCache {
path := slugify(rawURL)
if info, err := os.Stat(path); err == nil && info.Size() > 0 { if info, err := os.Stat(path); err == nil && info.Size() > 0 {
f, err := os.Open(path) f, err := os.Open(path)
if err == nil { if err == nil {
@ -311,7 +308,6 @@ func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeade
return body, nil return body, nil
} }
path := slugify(rawURL)
data, err := io.ReadAll(body) data, err := io.ReadAll(body)
body.Close() body.Close()
if err != nil { if err != nil {
@ -324,7 +320,7 @@ func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeade
fmt.Fprintf(os.Stderr, "cached to ./%s\n", path) fmt.Fprintf(os.Stderr, "cached to ./%s\n", path)
} }
return io.NopCloser(strings.NewReader(string(data))), nil return io.NopCloser(bytes.NewReader(data)), nil
} }
func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.ReadCloser, error) { func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.ReadCloser, error) {
@ -391,7 +387,8 @@ func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.R
} }
func isTimeout(err error) bool { func isTimeout(err error) bool {
if netErr, ok := err.(net.Error); ok { var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout() return netErr.Timeout()
} }
return strings.Contains(err.Error(), "deadline exceeded") || return strings.Contains(err.Error(), "deadline exceeded") ||

View File

@ -1,4 +1,4 @@
package jsontypes package main
import ( import (
"bufio" "bufio"
@ -8,7 +8,7 @@ import (
"strings" "strings"
) )
type Prompter struct { type prompter struct {
reader *bufio.Reader reader *bufio.Reader
output io.Writer output io.Writer
tty *os.File // non-nil if we opened /dev/tty tty *os.File // non-nil if we opened /dev/tty
@ -21,20 +21,15 @@ type Prompter struct {
// newPrompter creates a prompter. If the JSON input comes from stdin, we open // newPrompter creates a prompter. If the JSON input comes from stdin, we open
// /dev/tty for interactive prompts so they don't conflict. // /dev/tty for interactive prompts so they don't conflict.
func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) { func newPrompter(inputIsStdin bool) (*prompter, error) {
p := &Prompter{output: os.Stderr} p := &prompter{output: os.Stderr}
if inputIsStdin { if inputIsStdin {
if anonymous { tty, err := os.Open("/dev/tty")
// No prompts needed — use a closed reader that returns EOF if err != nil {
p.reader = bufio.NewReader(strings.NewReader("")) return nil, fmt.Errorf("cannot open /dev/tty for prompts (input is stdin): %w", err)
} else {
tty, err := os.Open("/dev/tty")
if err != nil {
return nil, fmt.Errorf("cannot open /dev/tty for prompts (input is stdin): %w", err)
}
p.tty = tty
p.reader = bufio.NewReader(tty)
} }
p.tty = tty
p.reader = bufio.NewReader(tty)
} else { } else {
p.reader = bufio.NewReader(os.Stdin) p.reader = bufio.NewReader(os.Stdin)
} }
@ -42,7 +37,7 @@ func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) {
} }
// loadAnswers reads prior answers from a file to use as defaults. // loadAnswers reads prior answers from a file to use as defaults.
func (p *Prompter) LoadAnswers(path string) { func (p *prompter) loadAnswers(path string) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return return
@ -59,7 +54,7 @@ func (p *Prompter) LoadAnswers(path string) {
} }
// saveAnswers writes this session's answers to a file. // saveAnswers writes this session's answers to a file.
func (p *Prompter) SaveAnswers(path string) error { func (p *prompter) saveAnswers(path string) error {
if len(p.answers) == 0 { if len(p.answers) == 0 {
return nil return nil
} }
@ -67,7 +62,7 @@ func (p *Prompter) SaveAnswers(path string) error {
} }
// nextPrior returns the next prior answer if available, or empty string. // nextPrior returns the next prior answer if available, or empty string.
func (p *Prompter) nextPrior() string { func (p *prompter) nextPrior() string {
if p.priorIdx < len(p.priorAnswers) { if p.priorIdx < len(p.priorAnswers) {
answer := p.priorAnswers[p.priorIdx] answer := p.priorAnswers[p.priorIdx]
p.priorIdx++ p.priorIdx++
@ -77,11 +72,11 @@ func (p *Prompter) nextPrior() string {
} }
// record saves an answer for later writing. // record saves an answer for later writing.
func (p *Prompter) record(answer string) { func (p *prompter) record(answer string) {
p.answers = append(p.answers, answer) p.answers = append(p.answers, answer)
} }
func (p *Prompter) Close() { func (p *prompter) close() {
if p.tty != nil { if p.tty != nil {
p.tty.Close() p.tty.Close()
} }
@ -90,7 +85,7 @@ func (p *Prompter) Close() {
// ask presents a prompt with a default and valid options. Returns the chosen // ask presents a prompt with a default and valid options. Returns the chosen
// option (lowercase). Options should be lowercase; the default is shown in // option (lowercase). Options should be lowercase; the default is shown in
// uppercase in the hint. // uppercase in the hint.
func (p *Prompter) ask(prompt, defaultOpt string, options []string) string { func (p *prompter) ask(prompt, defaultOpt string, options []string) string {
// Override default with prior answer if available // Override default with prior answer if available
if prior := p.nextPrior(); prior != "" { if prior := p.nextPrior(); prior != "" {
for _, o := range options { for _, o := range options {
@ -131,21 +126,14 @@ func (p *Prompter) ask(prompt, defaultOpt string, options []string) string {
} }
} }
// askMapOrName presents a combined map/struct+name prompt. Shows [Default/m]. // askMapOrName presents a combined map/struct+name prompt.
// Accepts: 'm' or 'map' → returns "m", a name starting with an uppercase func (p *prompter) askMapOrName(prompt, defaultVal string) string {
// letter → returns the name, empty → returns the default. Anything else
// re-prompts.
//
// Prior answers are interpreted generously: "s" (old struct answer) is treated
// as "accept the default struct name", "m" as map, and uppercase names as-is.
func (p *Prompter) askMapOrName(prompt, defaultVal string) string {
if prior := p.nextPrior(); prior != "" { if prior := p.nextPrior(); prior != "" {
if prior == "m" || prior == "map" { if prior == "m" || prior == "map" {
defaultVal = prior defaultVal = prior
} else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' { } else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
defaultVal = prior defaultVal = prior
} }
// Old-format answers like "s" → keep the inferred default (treat as "accept")
} }
hint := defaultVal + "/m" hint := defaultVal + "/m"
@ -178,16 +166,11 @@ func (p *Prompter) askMapOrName(prompt, defaultVal string) string {
} }
// askTypeName presents a prompt for a type name with a suggested default. // askTypeName presents a prompt for a type name with a suggested default.
// Accepts names starting with an uppercase letter. func (p *prompter) askTypeName(prompt, defaultVal string) string {
//
// Prior answers are interpreted generously: old-format answers that don't
// start with uppercase are treated as "accept the default".
func (p *Prompter) askTypeName(prompt, defaultVal string) string {
if prior := p.nextPrior(); prior != "" { if prior := p.nextPrior(); prior != "" {
if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' { if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
defaultVal = prior defaultVal = prior
} }
// Old-format answers → keep the inferred default (treat as "accept")
} }
for { for {
@ -209,26 +192,3 @@ func (p *Prompter) askTypeName(prompt, defaultVal string) string {
fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase)\n") fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase)\n")
} }
} }
// askFreeform presents a prompt with a suggested default. Returns user input
// or the default if they just press enter.
func (p *Prompter) askFreeform(prompt, defaultVal string) string {
// Override default with prior answer if available
if prior := p.nextPrior(); prior != "" {
defaultVal = prior
}
fmt.Fprintf(p.output, "%s [%s] ", prompt, defaultVal)
line, err := p.reader.ReadString('\n')
if err != nil {
p.record(defaultVal)
return defaultVal
}
line = strings.TrimSpace(line)
if line == "" {
p.record(defaultVal)
return defaultVal
}
p.record(line)
return line
}

View File

@ -0,0 +1,182 @@
package main
import (
"fmt"
"strings"
"github.com/therootcompany/golib/tools/jsontypes"
)
// newCLIResolver returns a jsontypes.Resolver that uses a prompter for terminal I/O.
func newCLIResolver(p *prompter) jsontypes.Resolver {
return func(d *jsontypes.Decision) error {
switch d.Kind {
case jsontypes.DecideMapOrStruct:
return cliMapOrStruct(p, d)
case jsontypes.DecideTypeName:
return cliTypeName(p, d)
case jsontypes.DecideTupleOrList:
return cliTupleOrList(p, d)
case jsontypes.DecideUnifyShapes:
return cliUnifyShapes(p, d)
case jsontypes.DecideShapeName:
return cliShapeName(p, d)
case jsontypes.DecideNameCollision:
return cliNameCollision(p, d)
default:
d.Response = d.Default
return nil
}
}
}
func cliMapOrStruct(p *prompter, d *jsontypes.Decision) error {
fmt.Fprintf(p.output, "\nAt %s\n", d.Path)
fmt.Fprintf(p.output, " Object with %d keys:\n", len(d.Fields))
for _, f := range d.Fields {
fmt.Fprintf(p.output, " %s: %s\n", f.Name, f.Preview)
}
defaultVal := d.Default.Name
if d.Default.IsMap {
defaultVal = "m"
}
answer := p.askMapOrName("Struct name (or 'm' for map)?", defaultVal)
if answer == "m" {
d.Response = jsontypes.Response{IsMap: true}
} else {
d.Response = jsontypes.Response{Name: answer}
}
return nil
}
func cliTypeName(p *prompter, d *jsontypes.Decision) error {
fmt.Fprintf(p.output, "\nAt %s\n", d.Path)
fmt.Fprintf(p.output, " Struct with %d fields:\n", len(d.Fields))
for _, f := range d.Fields {
fmt.Fprintf(p.output, " %s: %s\n", f.Name, f.Preview)
}
name := p.askTypeName("Name for this type?", d.Default.Name)
d.Response = jsontypes.Response{Name: name}
return nil
}
func cliTupleOrList(p *prompter, d *jsontypes.Decision) error {
fmt.Fprintf(p.output, "\nAt %s\n", d.Path)
fmt.Fprintf(p.output, " Short array with %d elements of mixed types:\n", len(d.Elements))
for _, e := range d.Elements {
fmt.Fprintf(p.output, " [%d]: %s\n", e.Index, e.Preview)
}
choice := p.ask("Is this a [l]ist or a [t]uple?", "l", []string{"l", "t"})
d.Response = jsontypes.Response{IsTuple: choice == "t"}
return nil
}
func cliUnifyShapes(p *prompter, d *jsontypes.Decision) error {
const maxFields = 8
totalInstances := 0
for _, s := range d.Shapes {
totalInstances += s.Instances
}
fmt.Fprintf(p.output, "\nAt %s — %d shapes (%d instances):\n",
d.Path, len(d.Shapes), totalInstances)
if len(d.SharedFields) > 0 {
preview := d.SharedFields
if len(preview) > maxFields {
preview = preview[:maxFields]
}
fmt.Fprintf(p.output, " shared fields (%d): %s", len(d.SharedFields), strings.Join(preview, ", "))
if len(d.SharedFields) > maxFields {
fmt.Fprintf(p.output, ", ...")
}
fmt.Fprintln(p.output)
} else {
fmt.Fprintf(p.output, " no shared fields\n")
}
shownShapes := d.Shapes
if len(shownShapes) > 5 {
shownShapes = shownShapes[:5]
}
for _, s := range shownShapes {
if len(s.UniqueFields) == 0 {
fmt.Fprintf(p.output, " shape %d (%d instances): no unique fields\n", s.Index+1, s.Instances)
continue
}
preview := s.UniqueFields
if len(preview) > maxFields {
preview = preview[:maxFields]
}
fmt.Fprintf(p.output, " shape %d (%d instances): +%d unique: %s",
s.Index+1, s.Instances, len(s.UniqueFields), strings.Join(preview, ", "))
if len(s.UniqueFields) > maxFields {
fmt.Fprintf(p.output, ", ...")
}
fmt.Fprintln(p.output)
}
if len(d.Shapes) > 5 {
fmt.Fprintf(p.output, " ... and %d more shapes\n", len(d.Shapes)-5)
}
defaultChoice := "s"
if d.Default.IsNewType {
defaultChoice = "d"
}
var choice string
for {
choice = p.ask("[s]ame type? [d]ifferent? show [f]ull list?",
defaultChoice, []string{"s", "d", "f"})
if choice != "f" {
break
}
for _, s := range d.Shapes {
fmt.Fprintf(p.output, " Shape %d (%d instances): %s\n",
s.Index+1, s.Instances, strings.Join(s.Fields, ", "))
}
}
d.Response = jsontypes.Response{IsNewType: choice == "d"}
return nil
}
func cliShapeName(p *prompter, d *jsontypes.Decision) error {
fmt.Fprintf(p.output, " Shape %d: %s\n",
d.ShapeIndex+1, strings.Join(fieldNames(d.Fields), ", "))
name := p.askTypeName(
fmt.Sprintf(" Name for shape %d?", d.ShapeIndex+1), d.Default.Name)
d.Response = jsontypes.Response{Name: name}
return nil
}
func cliNameCollision(p *prompter, d *jsontypes.Decision) error {
fmt.Fprintf(p.output, " Type %q already exists with overlapping fields: %s\n",
d.Default.Name, strings.Join(d.ExistingFields, ", "))
choice := p.ask(
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", d.Default.Name),
"e", []string{"e", "d"},
)
if choice == "e" {
d.Response = jsontypes.Response{Extend: true, Name: d.Default.Name}
} else {
name := p.askTypeName(" New name?", d.Default.Name)
d.Response = jsontypes.Response{Name: name}
}
return nil
}
func fieldNames(fields []jsontypes.FieldSummary) []string {
names := make([]string, len(fields))
for i, f := range fields {
names[i] = f.Name
}
return names
}

View File

@ -7,63 +7,46 @@ import (
) )
// decideMapOrStruct determines whether an object is a map or struct. // decideMapOrStruct determines whether an object is a map or struct.
// In anonymous mode, uses heuristics silently.
// Otherwise, shows a combined prompt: enter a TypeName or 'm' for map.
// In default mode, confident heuristic maps skip the prompt.
// In askTypes mode, the prompt is always shown.
func (a *Analyzer) decideMapOrStruct(path string, obj map[string]any) bool { func (a *Analyzer) decideMapOrStruct(path string, obj map[string]any) bool {
isMap, confident := looksLikeMap(obj) isMap, confident := looksLikeMap(obj)
if a.anonymous {
// Skip resolver when heuristics are confident and we're not in askTypes mode
if a.autonomous || (!a.askTypes && confident) {
return isMap return isMap
} }
// Default mode: skip prompt when heuristics are confident
if !a.askTypes && confident {
return isMap
}
return a.promptMapOrStructWithName(path, obj, isMap, confident)
}
// promptMapOrStructWithName shows the object's fields and asks a combined question.
// The user can type 'm' or 'map' for a map, a name starting with a capital letter
// for a struct type, or press Enter to accept the default.
func (a *Analyzer) promptMapOrStructWithName(path string, obj map[string]any, heuristicMap, confident bool) bool {
keys := sortedKeys(obj)
inferred := inferTypeName(path) inferred := inferTypeName(path)
if inferred == "" { if inferred == "" {
a.typeCounter++ a.typeCounter++
inferred = fmt.Sprintf("Struct%d", a.typeCounter) inferred = fmt.Sprintf("Struct%d", a.typeCounter)
} }
defaultVal := inferred def := Response{Name: inferred}
if confident && heuristicMap { if confident && isMap {
defaultVal = "m" def = Response{IsMap: true}
} }
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path)) d := &Decision{
fmt.Fprintf(a.Prompter.output, " Object with %d keys:\n", len(keys)) Kind: DecideMapOrStruct,
for _, k := range keys { Path: shortPath(path),
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k])) Default: def,
Fields: objectToFieldSummaries(obj),
} }
answer := a.Prompter.askMapOrName("Struct name (or 'm' for map)?", defaultVal) if err := a.resolver(d); err != nil {
if answer == "m" { return isMap
}
if d.Response.IsMap {
a.pendingTypeName = "" a.pendingTypeName = ""
return true return true
} }
a.pendingTypeName = answer a.pendingTypeName = d.Response.Name
return false return false
} }
// decideKeyName infers the map key type from the keys.
func (a *Analyzer) decideKeyName(_ string, obj map[string]any) string {
return inferKeyName(obj)
}
// decideTypeName determines the struct type name, using inference and optionally // decideTypeName determines the struct type name, using inference and optionally
// prompting the user. // asking the resolver.
func (a *Analyzer) decideTypeName(path string, obj map[string]any) string { func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
// Check if we've already named a type with this exact shape // Check if we've already named a type with this exact shape
sig := shapeSignature(obj) sig := shapeSignature(obj)
@ -74,7 +57,7 @@ func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
newFields := fieldSet(obj) newFields := fieldSet(obj)
// Consume pending name from askTypes combined prompt // Consume pending name from combined map/struct prompt
if a.pendingTypeName != "" { if a.pendingTypeName != "" {
name := a.pendingTypeName name := a.pendingTypeName
a.pendingTypeName = "" a.pendingTypeName = ""
@ -87,26 +70,19 @@ func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
inferred = fmt.Sprintf("Struct%d", a.typeCounter) inferred = fmt.Sprintf("Struct%d", a.typeCounter)
} }
// Default and anonymous modes: auto-resolve without prompting // Default and autonomous modes: auto-resolve without asking
if !a.askTypes { if !a.askTypes {
return a.autoResolveTypeName(path, inferred, newFields, sig) return a.autoResolveTypeName(path, inferred, newFields, sig)
} }
// askTypes mode: show fields and prompt for name // askTypes mode: ask the resolver
keys := sortedKeys(obj) name := a.resolveNameViaResolver(path, inferred, newFields, sig, obj)
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
fmt.Fprintf(a.Prompter.output, " Struct with %d fields:\n", len(keys))
for _, k := range keys {
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
}
name := a.promptName(path, inferred, newFields, sig)
return name return name
} }
// autoResolveTypeName registers or resolves a type name without prompting. // autoResolveTypeName registers or resolves a type name without prompting.
// On collision, tries the parent-prefix strategy; if that also collides, prompts // On collision, tries the parent-prefix strategy; if that also collides, prompts
// (unless anonymous, in which case it uses a numbered fallback). // (unless autonomous, in which case it uses a numbered fallback).
func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string { func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string {
existing, taken := a.typesByName[name] existing, taken := a.typesByName[name]
if !taken { if !taken {
@ -130,12 +106,12 @@ func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]s
return a.registerType(sig, alt, newFields) return a.registerType(sig, alt, newFields)
} }
// Parent strategy also taken // Parent strategy also taken
if a.anonymous { if a.autonomous {
a.typeCounter++ a.typeCounter++
return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields) return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields)
} }
// Last resort: prompt // Last resort: ask the resolver
return a.promptName(path, alt, newFields, sig) return a.resolveNameViaResolver(path, alt, newFields, sig, nil)
} }
} }
@ -158,17 +134,29 @@ func (a *Analyzer) resolveTypeName(path, name string, newFields map[string]strin
a.knownTypes[sig] = existing a.knownTypes[sig] = existing
return name return name
default: default:
return a.promptName(path, name, newFields, sig) return a.resolveNameViaResolver(path, name, newFields, sig, nil)
} }
} }
// promptName asks for a type name and handles collisions with existing types. // resolveNameViaResolver asks the resolver for a type name and handles
// Pre-resolves the suggested name so the user sees a valid default. // collisions. obj may be nil if field summaries are unavailable.
func (a *Analyzer) promptName(path, suggested string, newFields map[string]string, sig string) string { func (a *Analyzer) resolveNameViaResolver(path, suggested string, newFields map[string]string, sig string, obj map[string]any) string {
suggested = a.preResolveCollision(path, suggested, newFields, sig) suggested = a.preResolveCollision(path, suggested, newFields)
for { for {
name := a.Prompter.askFreeform("Name for this type?", suggested) d := &Decision{
Kind: DecideTypeName,
Path: shortPath(path),
Default: Response{Name: suggested},
Fields: objectToFieldSummaries(obj),
}
if err := a.resolver(d); err != nil {
return a.registerType(sig, suggested, newFields)
}
name := d.Response.Name
if name == "" {
name = suggested
}
existing, taken := a.typesByName[name] existing, taken := a.typesByName[name]
if !taken { if !taken {
@ -181,19 +169,22 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
a.knownTypes[sig] = existing a.knownTypes[sig] = existing
return name return name
case relSubset, relSuperset: case relSubset, relSuperset:
fmt.Fprintf(a.Prompter.output, " Extending existing type %q (merging fields)\n", name)
merged := mergeFieldSets(existing.fields, newFields) merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged existing.fields = merged
a.knownTypes[sig] = existing a.knownTypes[sig] = existing
return name return name
case relOverlap: case relOverlap:
fmt.Fprintf(a.Prompter.output, " Type %q already exists with overlapping fields: %s\n", cd := &Decision{
name, fieldList(existing.fields)) Kind: DecideNameCollision,
choice := a.Prompter.ask( Path: shortPath(path),
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", name), Default: Response{Name: name, Extend: true},
"e", []string{"e", "d"}, Fields: objectToFieldSummaries(obj),
) ExistingFields: fieldListSlice(existing.fields),
if choice == "e" { }
if err := a.resolver(cd); err != nil {
return a.registerType(sig, suggested, newFields)
}
if cd.Response.Extend {
merged := mergeFieldSets(existing.fields, newFields) merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged existing.fields = merged
a.knownTypes[sig] = existing a.knownTypes[sig] = existing
@ -202,8 +193,6 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
suggested = a.suggestAlternativeName(path, name) suggested = a.suggestAlternativeName(path, name)
continue continue
case relDisjoint: case relDisjoint:
fmt.Fprintf(a.Prompter.output, " Type %q already exists with different fields: %s\n",
name, fieldList(existing.fields))
suggested = a.suggestAlternativeName(path, name) suggested = a.suggestAlternativeName(path, name)
continue continue
} }
@ -211,9 +200,8 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
} }
// preResolveCollision checks if the suggested name collides with an existing // preResolveCollision checks if the suggested name collides with an existing
// type that can't be auto-merged. If so, prints a warning and returns a new // type that can't be auto-merged. If so, returns an alternative name.
// suggested name. func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string) string {
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string, sig string) string {
existing, taken := a.typesByName[suggested] existing, taken := a.typesByName[suggested]
if !taken { if !taken {
return suggested return suggested
@ -224,10 +212,7 @@ func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[str
case relEqual, relSubset, relSuperset: case relEqual, relSubset, relSuperset:
return suggested return suggested
default: default:
alt := a.suggestAlternativeName(path, suggested) return a.suggestAlternativeName(path, suggested)
fmt.Fprintf(a.Prompter.output, " (type %q already exists with different fields, suggesting %q)\n",
suggested, alt)
return alt
} }
} }
@ -371,12 +356,6 @@ func kindsCompatible(a, b string) bool {
return false return false
} }
// fieldsOverlap returns true if one field set is a subset or superset of the other.
func fieldsOverlap(a, b map[string]string) bool {
rel := fieldRelation(a, b)
return rel == relEqual || rel == relSubset || rel == relSuperset
}
func mergeFieldSets(a, b map[string]string) map[string]string { func mergeFieldSets(a, b map[string]string) map[string]string {
merged := make(map[string]string, len(a)+len(b)) merged := make(map[string]string, len(a)+len(b))
for k, v := range a { for k, v := range a {
@ -392,30 +371,32 @@ func mergeFieldSets(a, b map[string]string) map[string]string {
return merged return merged
} }
func fieldList(fields map[string]string) string { // decideTupleOrList asks whether a short mixed-type array is a tuple or list.
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// decideTupleOrList asks the user if a short mixed-type array is a tuple or list.
func (a *Analyzer) decideTupleOrList(path string, arr []any) bool { func (a *Analyzer) decideTupleOrList(path string, arr []any) bool {
if a.anonymous { if a.autonomous {
return false // default to list return false // default to list
} }
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
fmt.Fprintf(a.Prompter.output, " Short array with %d elements of mixed types:\n", len(arr)) elems := make([]ElementSummary, len(arr))
for i, v := range arr { for i, v := range arr {
fmt.Fprintf(a.Prompter.output, " [%d]: %s\n", i, valueSummary(v)) elems[i] = ElementSummary{
Index: i,
Kind: kindOf(v),
Preview: valueSummary(v),
}
} }
choice := a.Prompter.ask(
"Is this a [l]ist or a [t]uple?", d := &Decision{
"l", []string{"l", "t"}, Kind: DecideTupleOrList,
) Path: shortPath(path),
return choice == "t" Default: Response{IsTuple: false},
Elements: elems,
}
if err := a.resolver(d); err != nil {
return false
}
return d.Response.IsTuple
} }
// valueSummary returns a short human-readable summary of a JSON value. // valueSummary returns a short human-readable summary of a JSON value.
@ -454,6 +435,33 @@ func valueSummary(v any) string {
} }
} }
// objectToFieldSummaries builds FieldSummary entries from a JSON object.
func objectToFieldSummaries(obj map[string]any) []FieldSummary {
if obj == nil {
return nil
}
keys := sortedKeys(obj)
summaries := make([]FieldSummary, len(keys))
for i, k := range keys {
summaries[i] = FieldSummary{
Name: k,
Kind: kindOf(obj[k]),
Preview: valueSummary(obj[k]),
}
}
return summaries
}
// fieldListSlice returns sorted field names from a field set.
func fieldListSlice(fields map[string]string) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func fieldSet(obj map[string]any) map[string]string { func fieldSet(obj map[string]any) map[string]string {
fs := make(map[string]string, len(obj)) fs := make(map[string]string, len(obj))
for k, v := range obj { for k, v := range obj {

View File

@ -35,12 +35,16 @@
// dec.UseNumber() // dec.UseNumber()
// dec.Decode(&data) // dec.Decode(&data)
// //
// a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode // a := jsontypes.New(jsontypes.AnalyzerConfig{})
// defer a.Close()
//
// paths := jsontypes.FormatPaths(a.Analyze(".", data)) // paths := jsontypes.FormatPaths(a.Analyze(".", data))
// fmt.Print(jsontypes.GenerateTypeScript(paths)) // fmt.Print(jsontypes.GenerateTypeScript(paths))
// //
// Or use the one-shot API:
//
// out, _ := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{
// Format: jsontypes.FormatTypeScript,
// })
//
// # AI tool use // # AI tool use
// //
// This package is designed to be callable as an AI skill. Given a JSON // This package is designed to be callable as an AI skill. Given a JSON

View File

@ -57,7 +57,7 @@ func parsePath(path string) []segment {
return segments return segments
} }
// formatPaths converts fully-annotated flat paths into the display format where: // FormatPaths converts fully-annotated flat paths into the display format where:
// - The root type appears alone on the first line (no leading dot) // - The root type appears alone on the first line (no leading dot)
// - Each type introduction gets its own line // - Each type introduction gets its own line
// - Type annotations only appear on the rightmost (new) segment of each line // - Type annotations only appear on the rightmost (new) segment of each line

View File

@ -1,8 +1,6 @@
package jsontypes package jsontypes
import ( import (
"bufio"
"io"
"strings" "strings"
"testing" "testing"
) )
@ -132,18 +130,15 @@ func TestDifferentTypesEndToEnd(t *testing.T) {
} }
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"} obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
a := &Analyzer{ a := New(AnalyzerConfig{
Prompter: &Prompter{ // Root has 3 field-like keys → confident struct, no resolver call needed.
reader: bufio.NewReader(strings.NewReader("")), // Then items[] has 2 shapes → unification: different types, then names.
output: io.Discard, Resolver: scriptedResolver(
// Root has 3 field-like keys → confident struct, no prompt needed. Response{IsNewType: true}, // different types for shapes
// Then items[] has 2 shapes → unification prompt: "d" for different, Response{Name: "FileField"}, // name for shape 1
// then names for each shape. Response{Name: "FeatureField"}, // name for shape 2
priorAnswers: []string{"d", "FileField", "FeatureField"}, ),
}, })
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
}
rawPaths := a.Analyze(".", obj) rawPaths := a.Analyze(".", obj)
formatted := FormatPaths(rawPaths) formatted := FormatPaths(rawPaths)

View File

@ -49,7 +49,7 @@ func (u *goUnion) wrapperTypeName() string {
return u.name return u.name
} }
// generateGoStructs converts formatted flat paths into Go struct definitions // GenerateGoStructs converts formatted flat paths into Go struct definitions
// with json tags. When multiple types share an array/map position, it generates // with json tags. When multiple types share an array/map position, it generates
// a sealed interface, discriminator function, and wrapper type. // a sealed interface, discriminator function, and wrapper type.
func GenerateGoStructs(paths []string) string { func GenerateGoStructs(paths []string) string {

View File

@ -1,10 +1,8 @@
package jsontypes package jsontypes
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -650,15 +648,13 @@ func TestGoStructUnionEndToEnd(t *testing.T) {
} }
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"} obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
a := &Analyzer{ a := New(AnalyzerConfig{
Prompter: &Prompter{ Resolver: scriptedResolver(
reader: bufio.NewReader(strings.NewReader("")), Response{IsNewType: true}, // different types for shapes
output: io.Discard, Response{Name: "FileField"}, // name for shape 1
priorAnswers: []string{"d", "FileField", "FeatureField"}, Response{Name: "FeatureField"}, // name for shape 2
}, ),
knownTypes: make(map[string]*structType), })
typesByName: make(map[string]*structType),
}
rawPaths := a.Analyze(".", obj) rawPaths := a.Analyze(".", obj)
formatted := FormatPaths(rawPaths) formatted := FormatPaths(rawPaths)
goCode := GenerateGoStructs(formatted) goCode := GenerateGoStructs(formatted)

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generateJSDoc converts formatted flat paths into JSDoc @typedef annotations. // GenerateJSDoc converts formatted flat paths into JSDoc @typedef annotations.
func GenerateJSDoc(paths []string) string { func GenerateJSDoc(paths []string) string {
types, _ := buildGoTypes(paths) types, _ := buildGoTypes(paths)
if len(types) == 0 { if len(types) == 0 {

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generateJSONSchema converts formatted flat paths into a JSON Schema (draft 2020-12) document. // GenerateJSONSchema converts formatted flat paths into a JSON Schema (draft 2020-12) document.
func GenerateJSONSchema(paths []string) string { func GenerateJSONSchema(paths []string) string {
types, _ := buildGoTypes(paths) types, _ := buildGoTypes(paths)

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generatePython converts formatted flat paths into Python TypedDict definitions. // GeneratePython converts formatted flat paths into Python TypedDict definitions.
func GeneratePython(paths []string) string { func GeneratePython(paths []string) string {
types, _ := buildGoTypes(paths) types, _ := buildGoTypes(paths)
if len(types) == 0 { if len(types) == 0 {

142
tools/jsontypes/resolver.go Normal file
View File

@ -0,0 +1,142 @@
package jsontypes
// DecisionKind identifies the type of decision being requested.
type DecisionKind int
const (
// DecideMapOrStruct asks whether a JSON object is a map (dynamic keys)
// or a struct (fixed fields).
// Relevant Decision fields: Fields.
// Response: set IsMap=true, or set Name to a PascalCase type name.
DecideMapOrStruct DecisionKind = iota
// DecideTypeName asks what a struct type should be called.
// Relevant Decision fields: Fields.
// Response: set Name to a PascalCase type name.
DecideTypeName
// DecideTupleOrList asks whether a short mixed-type array is a tuple
// (fixed positional types) or a homogeneous list.
// Relevant Decision fields: Elements.
// Response: set IsTuple.
DecideTupleOrList
// DecideUnifyShapes asks whether multiple object shapes at the same
// JSON position represent the same type (with optional fields) or
// different types.
// Relevant Decision fields: Shapes, SharedFields.
// Response: set IsNewType to treat each shape as a separate type.
DecideUnifyShapes
// DecideShapeName asks what a specific shape variant should be called
// when the user chose "different types" for a UnifyShapes decision.
// Relevant Decision fields: ShapeIndex, Fields.
// Response: set Name to a PascalCase type name.
DecideShapeName
// DecideNameCollision asks what to do when a chosen type name is
// already registered with overlapping but incompatible fields.
// Relevant Decision fields: Fields (new), ExistingFields.
// Response: set Extend=true to merge fields, or set Name to a
// different PascalCase type name.
DecideNameCollision
)
// Decision represents a question posed during JSON analysis.
// The Kind field determines which context fields are populated.
type Decision struct {
Kind DecisionKind
Path string // JSON path being analyzed
Default Response // heuristic suggestion
// Fields describes the object's keys and value types.
// Populated for MapOrStruct, TypeName, ShapeName, NameCollision.
Fields []FieldSummary
// Elements describes array element values.
// Populated for TupleOrList.
Elements []ElementSummary
// Shapes describes multiple object shapes at the same position.
// Populated for UnifyShapes.
Shapes []ShapeSummary
// SharedFields lists field names common to all shapes.
// Populated for UnifyShapes.
SharedFields []string
// ShapeIndex identifies which shape is being named (0-based).
// Populated for ShapeName.
ShapeIndex int
// ExistingFields lists the fields of the already-registered type
// whose name collides with the requested name.
// Populated for NameCollision.
ExistingFields []string
// Response is set by the Resolver to communicate the decision.
Response Response
}
// Response carries the answer to a Decision.
// Which fields are meaningful depends on the Decision.Kind.
type Response struct {
// Name is a PascalCase type name.
// Used by MapOrStruct (when not a map), TypeName, ShapeName,
// and NameCollision (when not extending).
Name string
// IsMap indicates the object should be treated as a map.
// Used by MapOrStruct.
IsMap bool
// IsTuple indicates the array is a tuple.
// Used by TupleOrList.
IsTuple bool
// IsNewType indicates each shape should be a separate type rather than
// unifying into one type with optional fields.
// Used by UnifyShapes. Zero value (false) means unify into one type.
IsNewType bool
// Extend indicates the existing type should be extended with merged
// fields rather than choosing a new name.
// Used by NameCollision.
Extend bool
}
// FieldSummary describes a single field in a JSON object.
type FieldSummary struct {
Name string // JSON field name
Kind string // "string", "number", "bool", "null", "object", "array"
Preview string // human-readable value summary
}
// ElementSummary describes a single element in a JSON array.
type ElementSummary struct {
Index int
Kind string
Preview string
}
// ShapeSummary describes one shape group in a multi-shape decision.
type ShapeSummary struct {
Index int // 0-based shape index
Instances int // how many objects have this shape
Fields []string // all field names in this shape
UniqueFields []string // fields unique to this shape (not in other shapes)
}
// Resolver is called during analysis when a decision is needed.
// The resolver reads the Decision's context fields and sets
// Decision.Response before returning. If an error is returned,
// heuristic defaults are used for that decision.
//
// To accept the heuristic default: d.Response = d.Default.
type Resolver func(d *Decision) error
// defaultResolver accepts heuristic defaults for all decisions.
func defaultResolver(d *Decision) error {
d.Response = d.Default
return nil
}

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generateSQL converts formatted flat paths into SQL CREATE TABLE statements. // GenerateSQL converts formatted flat paths into SQL CREATE TABLE statements.
// Nested structs become separate tables with foreign key relationships. // Nested structs become separate tables with foreign key relationships.
// Arrays of structs get a join table or FK pointing back to the parent. // Arrays of structs get a join table or FK pointing back to the parent.
func GenerateSQL(paths []string) string { func GenerateSQL(paths []string) string {

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generateTypedef converts formatted flat paths into a JSON Typedef (RFC 8927) document. // GenerateTypedef converts formatted flat paths into a JSON Typedef (RFC 8927) document.
func GenerateTypedef(paths []string) string { func GenerateTypedef(paths []string) string {
types, _ := buildGoTypes(paths) types, _ := buildGoTypes(paths)
@ -92,6 +92,7 @@ func goTypeToJTDInner(goTyp string, typeMap map[string]goType, defs map[string]a
case "string": case "string":
return map[string]any{"type": "string"} return map[string]any{"type": "string"}
case "int64": case "int64":
// JTD (RFC 8927) has no int64 type; int32 is the largest integer type available.
return map[string]any{"type": "int32"} return map[string]any{"type": "int32"}
case "float64": case "float64":
return map[string]any{"type": "float64"} return map[string]any{"type": "float64"}

View File

@ -140,11 +140,7 @@ func analyzeAndFormat(t *testing.T, jsonStr string) []string {
if err := dec.Decode(&data); err != nil { if err := dec.Decode(&data); err != nil {
t.Fatalf("invalid test JSON: %v", err) t.Fatalf("invalid test JSON: %v", err)
} }
a, err := NewAnalyzer(false, true, false) a := New(AnalyzerConfig{})
if err != nil {
t.Fatalf("NewAnalyzer: %v", err)
}
defer a.Close()
rawPaths := a.Analyze(".", data) rawPaths := a.Analyze(".", data)
return FormatPaths(rawPaths) return FormatPaths(rawPaths)
} }

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generateTypeScript converts formatted flat paths into TypeScript interface definitions. // GenerateTypeScript converts formatted flat paths into TypeScript interface definitions.
func GenerateTypeScript(paths []string) string { func GenerateTypeScript(paths []string) string {
types, _ := buildGoTypes(paths) types, _ := buildGoTypes(paths)
if len(types) == 0 { if len(types) == 0 {

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
// generateZod converts formatted flat paths into Zod schema definitions. // GenerateZod converts formatted flat paths into Zod schema definitions.
func GenerateZod(paths []string) string { func GenerateZod(paths []string) string {
types, _ := buildGoTypes(paths) types, _ := buildGoTypes(paths)
if len(types) == 0 { if len(types) == 0 {