Add shared report value conversion
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Shared reporting and PromptKit v0.20.0 implementation plan
|
# Shared reporting and PromptKit v0.20.0 implementation plan
|
||||||
|
|
||||||
Status: ready for implementation; no stages completed.
|
Status: implementation in progress; Stage 1 complete.
|
||||||
|
|
||||||
## Scope, authority, and execution
|
## Scope, authority, and execution
|
||||||
|
|
||||||
@@ -61,6 +61,10 @@ writer, or upstream-domain correctness guarantee is added by this plan.
|
|||||||
|
|
||||||
## Stage 1: Shared report representation and safe YAML conversion
|
## Stage 1: Shared report representation and safe YAML conversion
|
||||||
|
|
||||||
|
**Complete.** JSON normalization and safe YAML conversion are implemented.
|
||||||
|
Focused conversion tests and the root workspace suite (`GOTOOLCHAIN=local go
|
||||||
|
test ./...` with Go 1.27.0) passed.
|
||||||
|
|
||||||
Start in the root package beside [reporter.go](../../reporter.go). Add private
|
Start in the root package beside [reporter.go](../../reporter.go). Add private
|
||||||
report normalization using `encoding/json`: marshal the original report value,
|
report normalization using `encoding/json`: marshal the original report value,
|
||||||
then decode with `UseNumber` into maps, sequences, scalars, null, and
|
then decode with `UseNumber` into maps, sequences, scalars, null, and
|
||||||
|
|||||||
229
report_value.go
Normal file
229
report_value.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeReport applies the JSON representation selected by the report's
|
||||||
|
// author while retaining numeric tokens for subsequent rendering.
|
||||||
|
func normalizeReport(value any) (any, error) {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var normalized any
|
||||||
|
if err := decoder.Decode(&normalized); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeYAMLReport decodes one YAML document from safe inspection output into
|
||||||
|
// JSON-compatible values. Unsupported YAML meanings return an error; the result
|
||||||
|
// is not a lossless representation of the input document.
|
||||||
|
func DecodeYAMLReport(reader io.Reader) (any, error) {
|
||||||
|
decoder := yaml.NewDecoder(reader)
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := decoder.Decode(&doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var extra yaml.Node
|
||||||
|
if err := decoder.Decode(&extra); err != io.EOF {
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errors.New("multiple YAML documents")
|
||||||
|
}
|
||||||
|
return yamlReportValue(&doc, make(map[*yaml.Node]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func yamlReportValue(node *yaml.Node, visiting map[*yaml.Node]bool) (any, error) {
|
||||||
|
if node == nil || visiting[node] {
|
||||||
|
return nil, errors.New("invalid or cyclic YAML alias")
|
||||||
|
}
|
||||||
|
visiting[node] = true
|
||||||
|
defer delete(visiting, node)
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.DocumentNode:
|
||||||
|
if len(node.Content) != 1 {
|
||||||
|
return nil, errors.New("invalid YAML document")
|
||||||
|
}
|
||||||
|
return yamlReportValue(node.Content[0], visiting)
|
||||||
|
case yaml.AliasNode:
|
||||||
|
return yamlReportValue(node.Alias, visiting)
|
||||||
|
case yaml.MappingNode:
|
||||||
|
if node.ShortTag() != "!!map" {
|
||||||
|
return nil, fmt.Errorf("unsupported YAML tag %s", node.Tag)
|
||||||
|
}
|
||||||
|
result := make(map[string]any, len(node.Content)/2)
|
||||||
|
for i := 0; i < len(node.Content); i += 2 {
|
||||||
|
key, err := yamlReportValue(node.Content[i], visiting)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name, ok := key.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("YAML mapping key is not a string")
|
||||||
|
}
|
||||||
|
if _, exists := result[name]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate YAML mapping key %q", name)
|
||||||
|
}
|
||||||
|
value, err := yamlReportValue(node.Content[i+1], visiting)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[name] = value
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
if node.ShortTag() != "!!seq" {
|
||||||
|
return nil, fmt.Errorf("unsupported YAML tag %s", node.Tag)
|
||||||
|
}
|
||||||
|
result := make([]any, 0, len(node.Content))
|
||||||
|
for _, child := range node.Content {
|
||||||
|
value, err := yamlReportValue(child, visiting)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
switch node.ShortTag() {
|
||||||
|
case "!!str":
|
||||||
|
// The YAML resolver falls back to a string when a numeric scalar is
|
||||||
|
// outside its machine-number range. Preserve explicit and quoted
|
||||||
|
// strings, but do not silently turn a plain numeric scalar into text.
|
||||||
|
if node.Style == 0 {
|
||||||
|
plain := strings.ReplaceAll(node.Value, "_", "")
|
||||||
|
if yamlInteger.MatchString(plain) {
|
||||||
|
return yamlReportNumber(node.Value, "!!int")
|
||||||
|
}
|
||||||
|
if yamlNumericPlain.MatchString(plain) {
|
||||||
|
return yamlReportNumber(node.Value, "!!float")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node.Value, nil
|
||||||
|
case "!!null":
|
||||||
|
return nil, nil
|
||||||
|
case "!!bool":
|
||||||
|
return strings.EqualFold(node.Value, "true"), nil
|
||||||
|
case "!!int", "!!float":
|
||||||
|
return yamlReportNumber(node.Value, node.ShortTag())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported YAML value with tag %s", node.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
var yamlDecimal = regexp.MustCompile(`^([+-]?)([0-9]*)(?:\.([0-9]*))?([eE][+-]?[0-9]+)?$`)
|
||||||
|
var yamlInteger = regexp.MustCompile(`^[+-]?(?:0[bB][01]+|0[oO][0-7]+|0[xX][0-9a-fA-F]+|[0-9]+)$`)
|
||||||
|
var yamlNumericPlain = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`)
|
||||||
|
var jsonNumberPattern = regexp.MustCompile(`^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$`)
|
||||||
|
|
||||||
|
func yamlReportNumber(value, tag string) (json.Number, error) {
|
||||||
|
original := value
|
||||||
|
value = strings.ReplaceAll(value, "_", "")
|
||||||
|
if tag == "!!int" {
|
||||||
|
n, ok := new(big.Int).SetString(value, 0)
|
||||||
|
if ok {
|
||||||
|
return json.Number(n.String()), nil
|
||||||
|
}
|
||||||
|
if !yamlNumericPlain.MatchString(value) {
|
||||||
|
return "", fmt.Errorf("unsupported YAML number %q", original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match := yamlDecimal.FindStringSubmatch(value)
|
||||||
|
if match == nil || (match[2] == "" && match[3] == "") {
|
||||||
|
return "", fmt.Errorf("unsupported YAML number %q", original)
|
||||||
|
}
|
||||||
|
integer := strings.TrimLeft(match[2], "0")
|
||||||
|
if integer == "" {
|
||||||
|
integer = "0"
|
||||||
|
}
|
||||||
|
sign := match[1]
|
||||||
|
if sign == "+" {
|
||||||
|
sign = ""
|
||||||
|
}
|
||||||
|
result := sign + integer
|
||||||
|
if strings.Contains(value, ".") {
|
||||||
|
fraction := match[3]
|
||||||
|
if fraction == "" {
|
||||||
|
fraction = "0"
|
||||||
|
}
|
||||||
|
result += "." + fraction
|
||||||
|
}
|
||||||
|
result += match[4]
|
||||||
|
if !json.Valid([]byte(result)) {
|
||||||
|
return "", fmt.Errorf("unsupported YAML number %q", original)
|
||||||
|
}
|
||||||
|
return json.Number(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportYAMLNode(value any) (*yaml.Node, error) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||||
|
case bool:
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(v)}, nil
|
||||||
|
case string:
|
||||||
|
return reportYAMLString(v), nil
|
||||||
|
case json.Number:
|
||||||
|
if !jsonNumberPattern.MatchString(string(v)) {
|
||||||
|
return nil, fmt.Errorf("invalid JSON number %q", v)
|
||||||
|
}
|
||||||
|
tag := "!!int"
|
||||||
|
if strings.ContainsAny(string(v), ".eE") {
|
||||||
|
tag = "!!float"
|
||||||
|
}
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: string(v)}, nil
|
||||||
|
case []any:
|
||||||
|
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||||
|
for _, item := range v {
|
||||||
|
child, err := reportYAMLNode(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content, child)
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
case map[string]any:
|
||||||
|
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||||
|
keys := make([]string, 0, len(v))
|
||||||
|
for key := range v {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
child, err := reportYAMLNode(v[key])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node.Content = append(node.Content, reportYAMLString(key), child)
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported normalized report value %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportYAMLString(value string) *yaml.Node {
|
||||||
|
node := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
|
||||||
|
plain := strings.ReplaceAll(value, "_", "")
|
||||||
|
if yamlInteger.MatchString(plain) || yamlNumericPlain.MatchString(plain) {
|
||||||
|
node.Style = yaml.DoubleQuotedStyle
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
135
report_value_test.go
Normal file
135
report_value_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package commandkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reportJSONValue struct{}
|
||||||
|
|
||||||
|
func (reportJSONValue) MarshalJSON() ([]byte, error) {
|
||||||
|
return []byte(`{"selected":123456789012345678901234567890,"text":"false"}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeReport(t *testing.T) {
|
||||||
|
value := struct {
|
||||||
|
Visible any `json:"visible"`
|
||||||
|
Hidden string `json:"-"`
|
||||||
|
Empty string `json:"empty,omitempty"`
|
||||||
|
}{Visible: reportJSONValue{}, Hidden: "secret"}
|
||||||
|
got, err := normalizeReport(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]any{"visible": map[string]any{
|
||||||
|
"selected": json.Number("123456789012345678901234567890"),
|
||||||
|
"text": "false",
|
||||||
|
}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if _, err := normalizeReport(make(chan int)); err == nil {
|
||||||
|
t.Fatal("unsupported value should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLReportValues(t *testing.T) {
|
||||||
|
input := `large: 123456789012345678901234567890
|
||||||
|
hex: 0xff
|
||||||
|
fraction: .125
|
||||||
|
text: "false"
|
||||||
|
redacted: "[REDACTED]"
|
||||||
|
zero: 0
|
||||||
|
no: false
|
||||||
|
nothing: null
|
||||||
|
empty_map: {}
|
||||||
|
empty_list: []
|
||||||
|
items: [one, "2", null]
|
||||||
|
`
|
||||||
|
got, err := DecodeYAMLReport(strings.NewReader(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]any{
|
||||||
|
"large": json.Number("123456789012345678901234567890"),
|
||||||
|
"hex": json.Number("255"), "fraction": json.Number("0.125"),
|
||||||
|
"text": "false", "redacted": "[REDACTED]", "zero": json.Number("0"),
|
||||||
|
"no": false, "nothing": nil, "empty_map": map[string]any{},
|
||||||
|
"empty_list": []any{}, "items": []any{"one", "2", nil},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
node, err := reportYAMLNode(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
roundtrip, err := DecodeYAMLReport(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", data, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(roundtrip, want) {
|
||||||
|
t.Fatalf("roundtrip %s: got %#v, want %#v", data, roundtrip, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeYAMLReportRejectsUnsupportedValues(t *testing.T) {
|
||||||
|
for _, input := range []string{"1: value", "a: .inf", "a: !custom value", "a: 1\n---\nb: 2", "a: &a [*a]", "a: 1\na: 2"} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
if _, err := DecodeYAMLReport(strings.NewReader(input)); err == nil {
|
||||||
|
t.Fatal("expected conversion error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLReportLargeNumericScalars(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ input, want string }{
|
||||||
|
{"1e9999", "1e9999"}, {"0x100000000000000000000", "1208925819614629174706176"},
|
||||||
|
{"+01.2500e+9999", "1.2500e+9999"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
got, err := DecodeYAMLReport(strings.NewReader(tc.input))
|
||||||
|
if err != nil || got != json.Number(tc.want) {
|
||||||
|
t.Fatalf("got %#v, %v; want %s", got, err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, input := range []string{`"1e9999"`, `!!str 1e9999`, `.`, `hello`} {
|
||||||
|
got, err := DecodeYAMLReport(strings.NewReader(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := got.(string); !ok {
|
||||||
|
t.Fatalf("%q became %#v", input, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportYAMLNodePreservesNumericLookingStrings(t *testing.T) {
|
||||||
|
value := map[string]any{"1e9999": "0x100000000000000000000", "number": json.Number("1e9999")}
|
||||||
|
node, err := reportYAMLNode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := DecodeYAMLReport(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", data, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, value) {
|
||||||
|
t.Fatalf("%s: got %#v, want %#v", data, got, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user