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"
)
// 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 {
Prompter *Prompter
anonymous bool
askTypes bool
resolver Resolver
autonomous bool
askTypes bool
typeCounter int
// knownTypes maps shape signature → type name
knownTypes map[string]*structType
// typesByName maps type name → structType for collision detection
knownTypes map[string]*structType
typesByName map[string]*structType
// pendingTypeName is set by the combined map/struct+name prompt
// and consumed by decideTypeName to avoid double-prompting
// pendingTypeName is set by decideMapOrStruct and consumed by
// decideTypeName to avoid double-prompting.
pendingTypeName string
}
@ -27,30 +44,27 @@ type structType struct {
}
type shapeGroup struct {
sig string
fields []string
members []map[string]any
}
func NewAnalyzer(inputIsStdin, anonymous, askTypes bool) (*Analyzer, error) {
p, err := NewPrompter(inputIsStdin, anonymous)
if err != nil {
return nil, err
// New creates an Analyzer with the given configuration.
func New(cfg AnalyzerConfig) *Analyzer {
r := cfg.Resolver
autonomous := r == nil
if r == nil {
r = defaultResolver
}
return &Analyzer{
Prompter: p,
anonymous: anonymous,
askTypes: askTypes,
resolver: r,
autonomous: autonomous,
askTypes: cfg.AskTypes,
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
}, nil
}
}
func (a *Analyzer) Close() {
a.Prompter.Close()
}
// analyze traverses a JSON value depth-first and returns annotated flat paths.
// Analyze traverses a JSON value depth-first and returns annotated flat paths.
func (a *Analyzer) Analyze(path string, val any) []string {
switch v := val.(type) {
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 {
keyName := a.decideKeyName(path, obj)
keyName := inferKeyName(obj)
// Collect all values and group by shape for type unification
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)
if a.isTupleCandidate(arr) {
if isTupleCandidate(arr) {
isTuple := a.decideTupleOrList(path, arr)
if isTuple {
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)
} else {
g := &shapeGroup{
sig: sig,
fields: sortedKeys(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)
}
// Multiple shapes — in anonymous mode default to same type
if a.anonymous {
// Multiple shapes — in autonomous mode default to same type
if a.autonomous {
return a.analyzeAsStructMulti(path, objects)
}
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)
}
// promptTypeUnification presents shape groups to the user and asks if they
// are the same type (with optional fields) or different types.
// promptTypeUnification presents shape groups and asks if they are the same
// type (with optional fields) or different types.
func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string {
const maxFields = 8
// Compute shared and unique fields across all shapes
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",
shortPath(path), len(groupOrder), totalInstances)
// 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 {
// Build shape summaries for the resolver
shapes := make([]ShapeSummary, len(groupOrder))
for i, sig := range groupOrder {
g := groups[sig]
unique := uniquePerShape[sig]
if len(unique) == 0 {
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): no unique fields\n", i+1, len(g.members))
continue
shapes[i] = ShapeSummary{
Index: i,
Instances: len(g.members),
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
// fields, default to "different". Ubiquitous fields (id, name, *_at, etc.)
// don't count as meaningful shared fields.
// fields, default to "different".
meaningfulShared := 0
for _, f := range shared {
if !isUbiquitousField(f) {
@ -372,34 +345,27 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr
for _, sig := range groupOrder {
totalUnique += len(uniquePerShape[sig])
}
defaultChoice := "s"
if totalUnique >= 2*meaningfulShared {
defaultChoice = "d"
defaultIsNewType := totalUnique >= 2*meaningfulShared
d := &Decision{
Kind: DecideUnifyShapes,
Path: shortPath(path),
Default: Response{IsNewType: defaultIsNewType},
Shapes: shapes,
SharedFields: shared,
}
// Combined prompt: same/different/show full list
var choice string
for {
choice = a.Prompter.ask(
"[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, ", "))
}
// Pre-collect all instances for the common "same type" path.
var all []map[string]any
for _, sig := range groupOrder {
all = append(all, groups[sig].members...)
}
if choice == "s" {
// Same type — analyze with all instances for field unification
var all []map[string]any
for _, sig := range groupOrder {
all = append(all, groups[sig].members...)
}
if err := a.resolver(d); err != nil {
return a.analyzeAsStructMulti(path, all)
}
if !d.Response.IsNewType {
return a.analyzeAsStructMulti(path, all)
}
@ -412,20 +378,29 @@ func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGr
a.typeCounter++
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
}
// Pre-resolve collision so the suggested name is valid
merged := mergeObjects(g.members)
newFields := fieldSet(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",
i+1, len(g.members), strings.Join(g.fields, ", "))
name := a.Prompter.askTypeName(
fmt.Sprintf(" Name for shape %d?", i+1), inferred)
names[i] = name
sd := &Decision{
Kind: DecideShapeName,
Path: shortPath(path),
Default: Response{Name: inferred},
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
a.registerType(shapeSig, name, newFields)
a.registerType(shapeSig, names[i], newFields)
}
// 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:
// 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 {
return false
}

View File

@ -1,28 +1,39 @@
package jsontypes
import (
"bufio"
"encoding/json"
"io"
"os"
"sort"
"strings"
"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 {
t.Helper()
a := &Analyzer{
Prompter: &Prompter{
reader: nil,
output: os.Stderr,
},
anonymous: true,
knownTypes: make(map[string]*structType),
typesByName: make(map[string]*structType),
return New(AnalyzerConfig{})
}
// scriptedResolver creates a Resolver that returns responses in order.
// After all responses are consumed, it accepts defaults.
func scriptedResolver(responses ...Response) Resolver {
i := 0
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 {
@ -36,14 +47,13 @@ func TestAnalyzePrimitive(t *testing.T) {
a := testAnalyzer(t)
tests := []struct {
name string
json string
want string
}{
{"null", "", ".{null}"},
{"bool", "", ".{bool}"},
{"int", "", ".{int}"},
{"float", "", ".{float}"},
{"string", "", ".{string}"},
{"null", ".{null}"},
{"bool", ".{bool}"},
{"int", ".{int}"},
{"float", ".{float}"},
{"string", ".{string}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -427,15 +437,19 @@ func TestAnalyzeFullSample(t *testing.T) {
assertPaths(t, paths, want)
}
// TestDifferentTypesPromptsForNames verifies that when the user chooses
// "different" for multiple shapes at the same path:
// 1. They are prompted to name each shape group
// 2. All names are collected BEFORE recursing into children
// 3. The named types appear in the final output
func TestDifferentTypesPromptsForNames(t *testing.T) {
// Simulate: a Room has items[] containing two distinct shapes, each with
// a nested "meta" object. Names should be asked for both shapes before
// the meta objects are analyzed.
// TestDifferentTypesViaResolver verifies that when the resolver returns
// IsNewType=true for multiple shapes at the same path:
// 1. Shape names are requested via DecideShapeName
// 2. The named types appear in the final output
func TestDifferentTypesViaResolver(t *testing.T) {
a := New(AnalyzerConfig{
Resolver: scriptedResolver(
Response{IsNewType: true}, // different types for shapes
Response{Name: "FileField"}, // name for shape 1
Response{Name: "FeatureField"}, // name for shape 2
),
})
arr := []any{
// Shape 1: has "filename" and "is_required"
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")}},
}
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))
// Verify both named types appear in the paths
hasFileField := false
hasFeatureField := false
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 "))
}
// 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
formatted := FormatPaths(paths)
foundFileField := false
@ -510,109 +502,64 @@ func TestDifferentTypesPromptsForNames(t *testing.T) {
}
}
// TestCombinedPromptShowsTypeName verifies the default-mode prompt shows
// [Root/m] (inferred name + map option), not [s/m] or [S/m].
func TestCombinedPromptShowsTypeName(t *testing.T) {
var output strings.Builder
a := &Analyzer{
Prompter: &Prompter{
reader: bufio.NewReader(strings.NewReader("")),
output: &output,
priorAnswers: []string{"Root"}, // accept default
// TestDecideMapOrStructDefault verifies that the library sends
// the inferred type name as the default in DecideMapOrStruct decisions.
func TestDecideMapOrStructDefault(t *testing.T) {
var captured *Decision
a := New(AnalyzerConfig{
Resolver: func(d *Decision) error {
if d.Kind == DecideMapOrStruct && captured == nil {
cp := *d
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
arr := []any{
map[string]any{"name": "Alice", "x": jsonNum("1")},
map[string]any{"name": "Bob", "y": jsonNum("2")},
obj := map[string]any{
"errors": []any{},
"rooms": []any{map[string]any{"name": "foo"}},
}
obj := map[string]any{"items": arr}
paths := sortPaths(a.Analyze(".", obj))
a.Analyze(".", obj)
// Should have Root type (from "s" → accept default) and Item type
// unified as same type (from "s" → same)
hasRoot := false
for _, p := range paths {
if strings.Contains(p, "{Root}") {
hasRoot = true
break
}
if captured == nil {
t.Fatal("expected DecideMapOrStruct decision")
}
if !hasRoot {
t.Errorf("expected {Root} type (old 's' should accept default), got:\n %s",
strings.Join(paths, "\n "))
if captured.Default.Name != "Root" {
t.Errorf("expected default name %q, got %q", "Root", captured.Default.Name)
}
}
// TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share
// 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) {
// Two shapes sharing only "slug" (ubiquitous) with 2+ unique fields each.
// With no prior answer for same/different, the default should be "d".
// Then we need type names for each shape.
// Shape ordering is insertion order: shape 1 = filename,is_required,slug; shape 2 = archived,feature,slug
a := testInteractiveAnalyzer(t, []string{
"Root", // root object has 1 key → not confident, prompts for struct/map
"d", // accept default (should be "d" because unique >> meaningful shared)
"FileField", // name for shape 1 (filename, is_required, slug)
"FeatureField", // name for shape 2 (archived, feature, slug)
var unifyDecision *Decision
a := New(AnalyzerConfig{
Resolver: func(d *Decision) error {
if d.Kind == DecideUnifyShapes && unifyDecision == nil {
cp := *d
unifyDecision = &cp
}
// For shape unification, accept the default; for other decisions
// 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{
@ -622,6 +569,13 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
obj := map[string]any{"items": arr}
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
hasFile := false
hasFeature := false
@ -640,14 +594,23 @@ func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
}
// 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) {
// Two shapes sharing "email", "phone", "address" (non-ubiquitous) with
// only 1 unique field each. unique (2) < 2 * meaningful shared (3), so
// default should be "s".
a := testInteractiveAnalyzer(t, []string{
"Root", // root object has 1 key → not confident, prompts for struct/map
"s", // accept default (should be "s")
var unifyDecision *Decision
a := New(AnalyzerConfig{
Resolver: func(d *Decision) error {
if d.Kind == DecideUnifyShapes && unifyDecision == nil {
cp := *d
unifyDecision = &cp
}
switch d.Kind {
case DecideMapOrStruct:
d.Response = Response{Name: "Root"}
default:
d.Response = d.Default
}
return nil
},
})
arr := []any{
@ -657,7 +620,14 @@ func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
obj := map[string]any{"people": arr}
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
for _, p := range paths {
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
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
import "github.com/therootcompany/golib/tools/jsontypes"
a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode
defer a.Close()
// One-shot: parse JSON and generate code in one call
out, err := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{
Format: jsontypes.FormatTypeScript,
})
// Or step by step:
a := jsontypes.New(jsontypes.AnalyzerConfig{})
var data any
// ... json.Decode with UseNumber() ...
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)

View File

@ -2,9 +2,11 @@ package main
import (
"bufio"
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@ -92,6 +94,12 @@ func main() {
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 baseName string // base filename for .paths and .answers files
inputIsStdin := true
@ -158,46 +166,34 @@ func main() {
os.Exit(1)
}
a, err := jsontypes.NewAnalyzer(inputIsStdin, *anonymous, *askTypes)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer a.Close()
// Load prior answers if available
if baseName != "" && !*anonymous {
a.Prompter.LoadAnswers(baseName + ".answers")
var resolver jsontypes.Resolver
var pr *prompter
if !*anonymous {
pr, err = newPrompter(inputIsStdin)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer pr.close()
if baseName != "" {
pr.loadAnswers(baseName + ".answers")
}
resolver = newCLIResolver(pr)
}
a := jsontypes.New(jsontypes.AnalyzerConfig{
Resolver: resolver,
AskTypes: *askTypes,
})
rawPaths := a.Analyze(".", data)
formatted := jsontypes.FormatPaths(rawPaths)
switch *format {
case "go":
fmt.Print(jsontypes.GenerateGoStructs(formatted))
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)
out, err := jsontypes.Generate(outFormat, formatted)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Print(out)
// Save outputs
if baseName != "" {
@ -206,9 +202,9 @@ func main() {
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err)
}
if !*anonymous {
if !*anonymous && pr != nil {
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)
}
}
@ -291,8 +287,9 @@ func isSensitiveParam(name string) bool {
}
func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) {
path := slugify(rawURL)
if !noCache {
path := slugify(rawURL)
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
f, err := os.Open(path)
if err == nil {
@ -311,7 +308,6 @@ func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeade
return body, nil
}
path := slugify(rawURL)
data, err := io.ReadAll(body)
body.Close()
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)
}
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) {
@ -391,7 +387,8 @@ func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.R
}
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 strings.Contains(err.Error(), "deadline exceeded") ||

View File

@ -1,4 +1,4 @@
package jsontypes
package main
import (
"bufio"
@ -8,7 +8,7 @@ import (
"strings"
)
type Prompter struct {
type prompter struct {
reader *bufio.Reader
output io.Writer
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
// /dev/tty for interactive prompts so they don't conflict.
func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) {
p := &Prompter{output: os.Stderr}
func newPrompter(inputIsStdin bool) (*prompter, error) {
p := &prompter{output: os.Stderr}
if inputIsStdin {
if anonymous {
// No prompts needed — use a closed reader that returns EOF
p.reader = bufio.NewReader(strings.NewReader(""))
} 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)
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)
} else {
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.
func (p *Prompter) LoadAnswers(path string) {
func (p *prompter) loadAnswers(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
@ -59,7 +54,7 @@ func (p *Prompter) LoadAnswers(path string) {
}
// 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 {
return nil
}
@ -67,7 +62,7 @@ func (p *Prompter) SaveAnswers(path string) error {
}
// 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) {
answer := p.priorAnswers[p.priorIdx]
p.priorIdx++
@ -77,11 +72,11 @@ func (p *Prompter) nextPrior() string {
}
// 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)
}
func (p *Prompter) Close() {
func (p *prompter) close() {
if p.tty != nil {
p.tty.Close()
}
@ -90,7 +85,7 @@ func (p *Prompter) Close() {
// ask presents a prompt with a default and valid options. Returns the chosen
// option (lowercase). Options should be lowercase; the default is shown in
// 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
if prior := p.nextPrior(); prior != "" {
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].
// Accepts: 'm' or 'map' → returns "m", a name starting with an uppercase
// 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 {
// askMapOrName presents a combined map/struct+name prompt.
func (p *prompter) askMapOrName(prompt, defaultVal string) string {
if prior := p.nextPrior(); prior != "" {
if prior == "m" || prior == "map" {
defaultVal = prior
} else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
defaultVal = prior
}
// Old-format answers like "s" → keep the inferred default (treat as "accept")
}
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.
// Accepts names starting with an uppercase letter.
//
// 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 {
func (p *prompter) askTypeName(prompt, defaultVal string) string {
if prior := p.nextPrior(); prior != "" {
if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
defaultVal = prior
}
// Old-format answers → keep the inferred default (treat as "accept")
}
for {
@ -209,26 +192,3 @@ func (p *Prompter) askTypeName(prompt, defaultVal string) string {
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.
// 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 {
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
}
// 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)
if inferred == "" {
a.typeCounter++
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
}
defaultVal := inferred
if confident && heuristicMap {
defaultVal = "m"
def := Response{Name: inferred}
if confident && isMap {
def = Response{IsMap: true}
}
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
fmt.Fprintf(a.Prompter.output, " Object with %d keys:\n", len(keys))
for _, k := range keys {
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
d := &Decision{
Kind: DecideMapOrStruct,
Path: shortPath(path),
Default: def,
Fields: objectToFieldSummaries(obj),
}
answer := a.Prompter.askMapOrName("Struct name (or 'm' for map)?", defaultVal)
if answer == "m" {
if err := a.resolver(d); err != nil {
return isMap
}
if d.Response.IsMap {
a.pendingTypeName = ""
return true
}
a.pendingTypeName = answer
a.pendingTypeName = d.Response.Name
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
// prompting the user.
// asking the resolver.
func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
// Check if we've already named a type with this exact shape
sig := shapeSignature(obj)
@ -74,7 +57,7 @@ func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
newFields := fieldSet(obj)
// Consume pending name from askTypes combined prompt
// Consume pending name from combined map/struct prompt
if a.pendingTypeName != "" {
name := 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)
}
// Default and anonymous modes: auto-resolve without prompting
// Default and autonomous modes: auto-resolve without asking
if !a.askTypes {
return a.autoResolveTypeName(path, inferred, newFields, sig)
}
// askTypes mode: show fields and prompt for name
keys := sortedKeys(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)
// askTypes mode: ask the resolver
name := a.resolveNameViaResolver(path, inferred, newFields, sig, obj)
return name
}
// autoResolveTypeName registers or resolves a type name without prompting.
// 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 {
existing, taken := a.typesByName[name]
if !taken {
@ -130,12 +106,12 @@ func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]s
return a.registerType(sig, alt, newFields)
}
// Parent strategy also taken
if a.anonymous {
if a.autonomous {
a.typeCounter++
return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields)
}
// Last resort: prompt
return a.promptName(path, alt, newFields, sig)
// Last resort: ask the resolver
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
return name
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.
// Pre-resolves the suggested name so the user sees a valid default.
func (a *Analyzer) promptName(path, suggested string, newFields map[string]string, sig string) string {
suggested = a.preResolveCollision(path, suggested, newFields, sig)
// resolveNameViaResolver asks the resolver for a type name and handles
// collisions. obj may be nil if field summaries are unavailable.
func (a *Analyzer) resolveNameViaResolver(path, suggested string, newFields map[string]string, sig string, obj map[string]any) string {
suggested = a.preResolveCollision(path, suggested, newFields)
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]
if !taken {
@ -181,19 +169,22 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
a.knownTypes[sig] = existing
return name
case relSubset, relSuperset:
fmt.Fprintf(a.Prompter.output, " Extending existing type %q (merging fields)\n", name)
merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged
a.knownTypes[sig] = existing
return name
case relOverlap:
fmt.Fprintf(a.Prompter.output, " Type %q already exists with overlapping fields: %s\n",
name, fieldList(existing.fields))
choice := a.Prompter.ask(
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", name),
"e", []string{"e", "d"},
)
if choice == "e" {
cd := &Decision{
Kind: DecideNameCollision,
Path: shortPath(path),
Default: Response{Name: name, Extend: true},
Fields: objectToFieldSummaries(obj),
ExistingFields: fieldListSlice(existing.fields),
}
if err := a.resolver(cd); err != nil {
return a.registerType(sig, suggested, newFields)
}
if cd.Response.Extend {
merged := mergeFieldSets(existing.fields, newFields)
existing.fields = merged
a.knownTypes[sig] = existing
@ -202,8 +193,6 @@ func (a *Analyzer) promptName(path, suggested string, newFields map[string]strin
suggested = a.suggestAlternativeName(path, name)
continue
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)
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
// type that can't be auto-merged. If so, prints a warning and returns a new
// suggested name.
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string, sig string) string {
// type that can't be auto-merged. If so, returns an alternative name.
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string) string {
existing, taken := a.typesByName[suggested]
if !taken {
return suggested
@ -224,10 +212,7 @@ func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[str
case relEqual, relSubset, relSuperset:
return suggested
default:
alt := a.suggestAlternativeName(path, suggested)
fmt.Fprintf(a.Prompter.output, " (type %q already exists with different fields, suggesting %q)\n",
suggested, alt)
return alt
return a.suggestAlternativeName(path, suggested)
}
}
@ -371,12 +356,6 @@ func kindsCompatible(a, b string) bool {
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 {
merged := make(map[string]string, len(a)+len(b))
for k, v := range a {
@ -392,30 +371,32 @@ func mergeFieldSets(a, b map[string]string) map[string]string {
return merged
}
func fieldList(fields map[string]string) string {
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.
// decideTupleOrList asks whether a short mixed-type array is a tuple or list.
func (a *Analyzer) decideTupleOrList(path string, arr []any) bool {
if a.anonymous {
if a.autonomous {
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 {
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?",
"l", []string{"l", "t"},
)
return choice == "t"
d := &Decision{
Kind: DecideTupleOrList,
Path: shortPath(path),
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.
@ -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 {
fs := make(map[string]string, len(obj))
for k, v := range obj {

View File

@ -35,12 +35,16 @@
// dec.UseNumber()
// dec.Decode(&data)
//
// a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode
// defer a.Close()
//
// a := jsontypes.New(jsontypes.AnalyzerConfig{})
// paths := jsontypes.FormatPaths(a.Analyze(".", data))
// fmt.Print(jsontypes.GenerateTypeScript(paths))
//
// Or use the one-shot API:
//
// out, _ := jsontypes.AutoGenerate(jsonBytes, jsontypes.Options{
// Format: jsontypes.FormatTypeScript,
// })
//
// # AI tool use
//
// 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
}
// 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)
// - Each type introduction gets its own line
// - Type annotations only appear on the rightmost (new) segment of each line

View File

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

View File

@ -49,7 +49,7 @@ func (u *goUnion) wrapperTypeName() string {
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
// a sealed interface, discriminator function, and wrapper type.
func GenerateGoStructs(paths []string) string {

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import (
"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 {
types, _ := buildGoTypes(paths)

View File

@ -5,7 +5,7 @@ import (
"strings"
)
// generatePython converts formatted flat paths into Python TypedDict definitions.
// GeneratePython converts formatted flat paths into Python TypedDict definitions.
func GeneratePython(paths []string) string {
types, _ := buildGoTypes(paths)
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"
)
// 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.
// Arrays of structs get a join table or FK pointing back to the parent.
func GenerateSQL(paths []string) string {

View File

@ -5,7 +5,7 @@ import (
"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 {
types, _ := buildGoTypes(paths)
@ -92,6 +92,7 @@ func goTypeToJTDInner(goTyp string, typeMap map[string]goType, defs map[string]a
case "string":
return map[string]any{"type": "string"}
case "int64":
// JTD (RFC 8927) has no int64 type; int32 is the largest integer type available.
return map[string]any{"type": "int32"}
case "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 {
t.Fatalf("invalid test JSON: %v", err)
}
a, err := NewAnalyzer(false, true, false)
if err != nil {
t.Fatalf("NewAnalyzer: %v", err)
}
defer a.Close()
a := New(AnalyzerConfig{})
rawPaths := a.Analyze(".", data)
return FormatPaths(rawPaths)
}

View File

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

View File

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