diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 24475b5..85fd7d8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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 @@ -61,6 +61,10 @@ writer, or upstream-domain correctness guarantee is added by this plan. ## 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 report normalization using `encoding/json`: marshal the original report value, then decode with `UseNumber` into maps, sequences, scalars, null, and diff --git a/report_value.go b/report_value.go new file mode 100644 index 0000000..61fe775 --- /dev/null +++ b/report_value.go @@ -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 +} diff --git a/report_value_test.go b/report_value_test.go new file mode 100644 index 0000000..1fba8fe --- /dev/null +++ b/report_value_test.go @@ -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) + } +}