From a02561d40f5f100274a30ad26c06a96afb5a4615 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 14:02:20 +0000 Subject: [PATCH] Centralize trusted JSON value cloning --- convert.go | 83 +------------------------ docs/roadmap/implementation.md | 4 ++ internal/jsonvalue/trusted.go | 97 ++++++++++++++++++++++++++++++ internal/jsonvalue/trusted_test.go | 27 +++++++++ 4 files changed, 130 insertions(+), 81 deletions(-) create mode 100644 internal/jsonvalue/trusted.go create mode 100644 internal/jsonvalue/trusted_test.go diff --git a/convert.go b/convert.go index ca9ae04..5b06d95 100644 --- a/convert.go +++ b/convert.go @@ -2,7 +2,6 @@ package promptkit import ( "fmt" - "reflect" "unicode/utf8" "go.maximumdirect.net/promptkit/internal/domain" @@ -395,88 +394,10 @@ func copyAnyMap(src map[string]any) map[string]any { if src == nil { return nil } - out := make(map[string]any, len(src)) - for k, v := range src { - out[k] = copyAny(v) - } - return out + return jsonvalue.CloneTrusted(src).(map[string]any) } -func copyAny(value any) any { - if value == nil { - return nil - } - switch v := value.(type) { - case map[string]any: - return copyAnyMap(v) - case []any: - out := make([]any, len(v)) - for i, item := range v { - out[i] = copyAny(item) - } - return out - case []string: - return copyStringSlice(v) - case []byte: - return copyBytes(v) - default: - return copyReflectValue(reflect.ValueOf(value)).Interface() - } -} - -func copyReflectValue(value reflect.Value) reflect.Value { - if !value.IsValid() { - return value - } - - switch value.Kind() { - case reflect.Interface: - if value.IsNil() { - return reflect.Zero(value.Type()) - } - copied := copyReflectValue(value.Elem()) - if copied.IsValid() && copied.Type().AssignableTo(value.Type()) { - return copied - } - out := reflect.New(value.Type()).Elem() - out.Set(copied) - return out - case reflect.Pointer: - if value.IsNil() { - return reflect.Zero(value.Type()) - } - out := reflect.New(value.Type().Elem()) - out.Elem().Set(copyReflectValue(value.Elem())) - return out - case reflect.Map: - if value.IsNil() { - return reflect.Zero(value.Type()) - } - out := reflect.MakeMapWithSize(value.Type(), value.Len()) - iter := value.MapRange() - for iter.Next() { - out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value())) - } - return out - case reflect.Slice: - if value.IsNil() { - return reflect.Zero(value.Type()) - } - out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap()) - for i := 0; i < value.Len(); i++ { - out.Index(i).Set(copyReflectValue(value.Index(i))) - } - return out - case reflect.Array: - out := reflect.New(value.Type()).Elem() - for i := 0; i < value.Len(); i++ { - out.Index(i).Set(copyReflectValue(value.Index(i))) - } - return out - default: - return value - } -} +func copyAny(value any) any { return jsonvalue.CloneTrusted(value) } func copyStringSlice(src []string) []string { if src == nil { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2b48559..96750c2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -423,6 +423,10 @@ not performance guarantees. mutation tests use independently specified expectations. Include distinguishing nil/type/schema-key cases; source conversion from Stage 2 must not reappear here. +Completion: trusted nested-value cloning is owned by internal/jsonvalue while +bounded input copying remains separate. Focused type, nil, schema-key, client, +prepared-state, and inspection tests pass on Go 1.27.3. + ## Stage 16: Stop Recopying Growing Condition Evidence **Catalog:** 10. **Files:** prompt requirements evaluation and focused tests. diff --git a/internal/jsonvalue/trusted.go b/internal/jsonvalue/trusted.go new file mode 100644 index 0000000..d2e0234 --- /dev/null +++ b/internal/jsonvalue/trusted.go @@ -0,0 +1,97 @@ +package jsonvalue + +import "reflect" + +func cloneTrustedMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + out := make(map[string]any, len(src)) + for k, v := range src { + out[k] = CloneTrusted(v) + } + return out +} + +// CloneTrusted detaches a previously accepted value without revalidating it. +func CloneTrusted(value any) any { + if value == nil { + return nil + } + switch v := value.(type) { + case map[string]any: + return cloneTrustedMap(v) + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = CloneTrusted(item) + } + return out + case []string: + if v == nil { + return []string(nil) + } + return append([]string{}, v...) + case []byte: + if v == nil { + return []byte(nil) + } + return append([]byte{}, v...) + default: + return cloneTrustedReflect(reflect.ValueOf(value)).Interface() + } +} + +func cloneTrustedReflect(value reflect.Value) reflect.Value { + if !value.IsValid() { + return value + } + + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copied := cloneTrustedReflect(value.Elem()) + if copied.IsValid() && copied.Type().AssignableTo(value.Type()) { + return copied + } + out := reflect.New(value.Type()).Elem() + out.Set(copied) + return out + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + out := reflect.New(value.Type().Elem()) + out.Elem().Set(cloneTrustedReflect(value.Elem())) + return out + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + out := reflect.MakeMapWithSize(value.Type(), value.Len()) + iter := value.MapRange() + for iter.Next() { + out.SetMapIndex(cloneTrustedReflect(iter.Key()), cloneTrustedReflect(iter.Value())) + } + return out + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap()) + for i := 0; i < value.Len(); i++ { + out.Index(i).Set(cloneTrustedReflect(value.Index(i))) + } + return out + case reflect.Array: + out := reflect.New(value.Type()).Elem() + for i := 0; i < value.Len(); i++ { + out.Index(i).Set(cloneTrustedReflect(value.Index(i))) + } + return out + default: + return value + } +} diff --git a/internal/jsonvalue/trusted_test.go b/internal/jsonvalue/trusted_test.go new file mode 100644 index 0000000..21bd55c --- /dev/null +++ b/internal/jsonvalue/trusted_test.go @@ -0,0 +1,27 @@ +package jsonvalue + +import ( + "encoding/json" + "reflect" + "testing" +) + +type trustedNamed string + +func TestCloneTrustedPreservesTypesAndDetaches(t *testing.T) { + named := trustedNamed("value") + original := map[string]any{"": map[string]any{"number": json.Number("9007199254740993")}, "typed": []string{"original"}, "pointer": &named, "nilmap": map[string]any(nil), "nilslice": []string(nil), "empty": []any{}} + copy := CloneTrusted(original).(map[string]any) + if !reflect.DeepEqual(copy, original) { + t.Fatalf("clone = %#v", copy) + } + copy[""].(map[string]any)["number"] = json.Number("1") + copy["typed"].([]string)[0] = "changed" + *copy["pointer"].(*trustedNamed) = "changed" + if original[""].(map[string]any)["number"] != json.Number("9007199254740993") || original["typed"].([]string)[0] != "original" || named != "value" { + t.Fatal("clone shares mutable storage") + } + if copy["nilmap"].(map[string]any) != nil || copy["nilslice"].([]string) != nil || copy["empty"].([]any) == nil { + t.Fatal("nil/empty shape changed") + } +}