diff --git a/marshaller/coremodel.go b/marshaller/coremodel.go index 75f80c9f..8ea33f6a 100644 --- a/marshaller/coremodel.go +++ b/marshaller/coremodel.go @@ -177,6 +177,8 @@ func (c *CoreModel) Marshal(ctx context.Context, w io.Writer) error { resetNodeStylesForYAML(nodeToMarshal, cfg) } + yml.StabilizeFoldedScalars(nodeToMarshal) + enc := yaml.NewEncoder(w) enc.SetIndent(cfg.Indentation) if err := enc.Encode(nodeToMarshal); err != nil { diff --git a/openapi/foldedscalar_marshalling_test.go b/openapi/foldedscalar_marshalling_test.go new file mode 100644 index 00000000..a7b7d753 --- /dev/null +++ b/openapi/foldedscalar_marshalling_test.go @@ -0,0 +1,52 @@ +package openapi_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The trailing table row is indented one space further than the rows above it, which +// makes yaml.v3 emit an extra line break before it on every encode. Marshalling has to +// stay a fixed point regardless of whether an overlay was involved. +const foldedScalarDocument = `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 + description: >- + ### Widgets + + | Name | Kind | + | ---- | ---- | + | acme | ` + "`petstore`" + ` | +paths: {} +` + +func TestMarshal_FoldedScalar_SurvivesRepeatedRoundTrips(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(foldedScalarDocument)) + require.NoError(t, err) + require.Empty(t, validationErrs) + + want := doc.Info.GetDescription() + require.Contains(t, want, "| acme |") + + current := foldedScalarDocument + for i := range 3 { + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(current)) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, openapi.Marshal(ctx, doc, &buf)) + + current = buf.String() + assert.Equal(t, want, doc.Info.GetDescription(), "value changed after %d round trips", i+1) + } +} diff --git a/openapi/localize.go b/openapi/localize.go index 25757109..f5dc3cdf 100644 --- a/openapi/localize.go +++ b/openapi/localize.go @@ -14,6 +14,7 @@ import ( "github.com/speakeasy-api/openapi/references" "github.com/speakeasy-api/openapi/sequencedmap" "github.com/speakeasy-api/openapi/system" + "github.com/speakeasy-api/openapi/yml" "gopkg.in/yaml.v3" ) @@ -581,6 +582,8 @@ func rewriteInternalReferences(content []byte, originalRef string, storage *loca } // Marshal back to YAML + yml.StabilizeFoldedScalars(&node) + updatedContent, err := yaml.Marshal(&node) if err != nil { return nil, fmt.Errorf("failed to marshal updated YAML: %w", err) diff --git a/openapi/testdata/localize/input/components.yaml b/openapi/testdata/localize/input/components.yaml index f29b3078..8a67a714 100644 --- a/openapi/testdata/localize/input/components.yaml +++ b/openapi/testdata/localize/input/components.yaml @@ -2,6 +2,14 @@ components: schemas: User: type: object + # The last row is deliberately indented further: that is what makes yaml.v3 + # inject a line break into a folded scalar on encode. + description: >- + ### User + + | Field | Kind | + | ----- | ---- | + | id | string | required: - id - name diff --git a/openapi/testdata/localize/output_counter/components.yaml b/openapi/testdata/localize/output_counter/components.yaml index f12b990c..6013b031 100644 --- a/openapi/testdata/localize/output_counter/components.yaml +++ b/openapi/testdata/localize/output_counter/components.yaml @@ -2,6 +2,12 @@ components: schemas: User: type: object + # The last row is deliberately indented further: that is what makes yaml.v3 + # inject a line break into a folded scalar on encode. + description: |- + ### User + | Field | Kind | | ----- | ---- | + | id | string | required: - id - name diff --git a/openapi/testdata/localize/output_pathbased/components.yaml b/openapi/testdata/localize/output_pathbased/components.yaml index f12b990c..6013b031 100644 --- a/openapi/testdata/localize/output_pathbased/components.yaml +++ b/openapi/testdata/localize/output_pathbased/components.yaml @@ -2,6 +2,12 @@ components: schemas: User: type: object + # The last row is deliberately indented further: that is what makes yaml.v3 + # inject a line break into a folded scalar on encode. + description: |- + ### User + | Field | Kind | | ----- | ---- | + | id | string | required: - id - name diff --git a/oq/foldedscalar_format_test.go b/oq/foldedscalar_format_test.go new file mode 100644 index 00000000..aa6e76d6 --- /dev/null +++ b/oq/foldedscalar_format_test.go @@ -0,0 +1,114 @@ +package oq_test + +import ( + "strings" + "testing" + + "github.com/speakeasy-api/openapi/graph" + "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/oq" + "github.com/speakeasy-api/openapi/references" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// The trailing table row is indented one space further than the rows above it, which +// makes yaml.v3 emit an extra line break before it on every encode. The break lands +// inside the scalar, so it changes the decoded value rather than just the layout. +const foldedScalarSpec = `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {} +components: + schemas: + Widget: + type: object + description: >- + ### Widgets + + | Name | Kind | + | ---- | ---- | + | acme | ` + "`petstore`" + ` | +` + +func loadFoldedScalarGraph(t *testing.T) *graph.SchemaGraph { + t.Helper() + + ctx := t.Context() + + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(foldedScalarSpec), openapi.WithSkipValidation()) + require.NoError(t, err) + require.NotNil(t, doc) + + idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ + RootDocument: doc, + TargetDocument: doc, + TargetLocation: "spec.yaml", + }) + + return graph.Build(ctx, idx) +} + +// formattedDescription pulls the description back out of a `key:\n ` wrapper. +func formattedDescription(t *testing.T, formatted string) string { + t.Helper() + + var decoded map[string]struct { + Description string `yaml:"description"` + } + require.NoError(t, yaml.Unmarshal([]byte(formatted), &decoded)) + require.Len(t, decoded, 1) + + for _, schema := range decoded { + return schema.Description + } + + return "" +} + +// `openapi spec query --format yaml` marshals graph nodes with its own encoder, so it +// needs the same stabilization as every other encode boundary. +func TestFormatYAML_FoldedScalarKeepsItsValue(t *testing.T) { + t.Parallel() + + var want struct { + Components struct { + Schemas map[string]struct { + Description string `yaml:"description"` + } `yaml:"schemas"` + } `yaml:"components"` + } + require.NoError(t, yaml.Unmarshal([]byte(foldedScalarSpec), &want)) + wantDescription := want.Components.Schemas["Widget"].Description + require.Contains(t, wantDescription, "| acme |") + + g := loadFoldedScalarGraph(t) + + result, err := oq.Execute(`schemas | where(name == "Widget")`, g) + require.NoError(t, err) + require.Len(t, result.Rows, 1) + + formatted := oq.FormatYAML(result, g) + require.NotEmpty(t, formatted) + + assert.Equal(t, wantDescription, formattedDescription(t, formatted)) + assert.NotContains(t, formatted, ">-", "affected folded scalars should be emitted as literal blocks") +} + +// Formatting the same result twice must not drift, since FormatYAML restyles graph +// nodes in place. +func TestFormatYAML_FoldedScalarIsAFixedPoint(t *testing.T) { + t.Parallel() + + g := loadFoldedScalarGraph(t) + + result, err := oq.Execute(`schemas | where(name == "Widget")`, g) + require.NoError(t, err) + + first := oq.FormatYAML(result, g) + for i := range 3 { + assert.Equal(t, first, oq.FormatYAML(result, g), "output changed on format %d", i+2) + } +} diff --git a/oq/format.go b/oq/format.go index ebbd5921..10c68eb3 100644 --- a/oq/format.go +++ b/oq/format.go @@ -8,6 +8,7 @@ import ( gcf "github.com/blackwell-systems/gcf-go" "github.com/speakeasy-api/openapi/graph" "github.com/speakeasy-api/openapi/oq/expr" + "github.com/speakeasy-api/openapi/yml" "gopkg.in/yaml.v3" ) @@ -310,6 +311,8 @@ func FormatYAML(result *Result, g *graph.SchemaGraph) string { node, }, } + yml.StabilizeFoldedScalars(wrapper) + data, err := yaml.Marshal(wrapper) if err != nil { sb.WriteString("# error marshalling: " + err.Error() + "\n") diff --git a/overlay/apply.go b/overlay/apply.go index acaa8cc4..170734c8 100644 --- a/overlay/apply.go +++ b/overlay/apply.go @@ -6,6 +6,7 @@ import ( "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" "github.com/speakeasy-api/jsonpath/pkg/jsonpath/token" + "github.com/speakeasy-api/openapi/yml" "gopkg.in/yaml.v3" ) @@ -30,6 +31,8 @@ func (o *Overlay) ApplyTo(root *yaml.Node) error { } } + yml.StabilizeFoldedScalars(root) + return nil } @@ -84,6 +87,9 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) { if len(multiError) > 0 { return warnings, fmt.Errorf("error applying overlay (strict): %v", strings.Join(multiError, ",")) } + + yml.StabilizeFoldedScalars(root) + return warnings, nil } diff --git a/overlay/foldedscalar.go b/overlay/foldedscalar.go new file mode 100644 index 00000000..0a9f21c3 --- /dev/null +++ b/overlay/foldedscalar.go @@ -0,0 +1,17 @@ +package overlay + +import ( + "github.com/speakeasy-api/openapi/yml" +) + +// stabilizeFoldedScalars restyles folded block scalars in the overlay's own update +// payloads so that serializing the overlay round trips unchanged. +func (o *Overlay) stabilizeFoldedScalars() { + if o == nil { + return + } + + for i := range o.Actions { + yml.StabilizeFoldedScalars(&o.Actions[i].Update) + } +} diff --git a/overlay/foldedscalar_test.go b/overlay/foldedscalar_test.go new file mode 100644 index 00000000..9414ca6d --- /dev/null +++ b/overlay/foldedscalar_test.go @@ -0,0 +1,179 @@ +package overlay_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/speakeasy-api/openapi/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// The trailing table row is indented one space further than the rows above it, +// which is what triggers the yaml.v3 emitter defect these tests guard against. +const foldedWithMoreIndentedLine = `description: >- + ### Widgets + + | Name | Kind | + | ---- | ---- | + | acme | ` + "`petstore`" + ` | +` + +func testOverlay() *overlay.Overlay { + return &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{Title: "Test", Version: "1.0.0"}, + Actions: []overlay.Action{ + { + Target: "$.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "Updated"}, + }, + }, + } +} + +func decodeDescription(t *testing.T, doc string) string { + t.Helper() + + var decoded struct { + Description string `yaml:"description"` + } + require.NoError(t, yaml.Unmarshal([]byte(doc), &decoded)) + + return decoded.Description +} + +func TestApplyToSurvivesRepeatedApplies(t *testing.T) { + t.Parallel() + + o := testOverlay() + + doc := "title: Original\n" + foldedWithMoreIndentedLine + want := decodeDescription(t, doc) + + for i := range 3 { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(doc), &node)) + require.NoError(t, o.ApplyTo(&node)) + + out, err := yaml.Marshal(&node) + require.NoError(t, err) + + doc = string(out) + assert.Equal(t, want, decodeDescription(t, doc), "value changed after %d applies", i+1) + } +} + +func TestApplyToStrictSurvivesRepeatedApplies(t *testing.T) { + t.Parallel() + + o := testOverlay() + + doc := "title: Original\n" + foldedWithMoreIndentedLine + want := decodeDescription(t, doc) + + for i := range 3 { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(doc), &node)) + _, err := o.ApplyToStrict(&node) + require.NoError(t, err) + + out, err := yaml.Marshal(&node) + require.NoError(t, err) + + doc = string(out) + assert.Equal(t, want, decodeDescription(t, doc), "value changed after %d applies", i+1) + } +} + +// An overlay carries folded scalars of its own, in the update payloads it applies. +// Format and ToString are separate entry points and each stabilizes independently. +func TestFormatSurvivesRepeatedRoundTrips(t *testing.T) { + t.Parallel() + + src := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: + - target: $.info + update: +` + indent(foldedWithMoreIndentedLine, " ") + + serializers := map[string]func(*overlay.Overlay) (string, error){ + "ToString": func(o *overlay.Overlay) (string, error) { + return o.ToString() + }, + "Format": func(o *overlay.Overlay) (string, error) { + var buf bytes.Buffer + if err := o.Format(&buf); err != nil { + return "", err + } + + return buf.String(), nil + }, + } + + for name, serialize := range serializers { + t.Run(name, func(t *testing.T) { + t.Parallel() + + want := updateDescription(t, src) + + doc := src + for i := range 3 { + o, err := overlay.ParseReader(strings.NewReader(doc)) + require.NoError(t, err) + + formatted, err := serialize(o) + require.NoError(t, err) + + doc = formatted + assert.Equal(t, want, updateDescription(t, doc), "value changed after %d round trips", i+1) + } + }) + } +} + +// A nil overlay serializes as "null"; stabilizing must not change that. +func TestFormatToleratesNilOverlay(t *testing.T) { + t.Parallel() + + var o *overlay.Overlay + + formatted, err := o.ToString() + require.NoError(t, err) + assert.Equal(t, "null\n", formatted) + + var buf bytes.Buffer + require.NoError(t, o.Format(&buf)) + assert.Equal(t, "null\n", buf.String()) +} + +func indent(doc string, prefix string) string { + lines := strings.Split(strings.TrimSuffix(doc, "\n"), "\n") + for i, line := range lines { + if line != "" { + lines[i] = prefix + line + } + } + + return strings.Join(lines, "\n") + "\n" +} + +func updateDescription(t *testing.T, doc string) string { + t.Helper() + + o, err := overlay.ParseReader(strings.NewReader(doc)) + require.NoError(t, err) + require.Len(t, o.Actions, 1) + + var decoded struct { + Description string `yaml:"description"` + } + require.NoError(t, o.Actions[0].Update.Decode(&decoded)) + + return decoded.Description +} diff --git a/overlay/parse.go b/overlay/parse.go index e3fb93c1..3a7e817c 100644 --- a/overlay/parse.go +++ b/overlay/parse.go @@ -65,6 +65,8 @@ func Format(path string) error { // Format writes the file back out as YAML. func (o *Overlay) Format(w io.Writer) error { + o.stabilizeFoldedScalars() + enc := yaml.NewEncoder(w) enc.SetIndent(2) return enc.Encode(o) diff --git a/overlay/schema.go b/overlay/schema.go index 1d00e7fb..7a4e6db2 100644 --- a/overlay/schema.go +++ b/overlay/schema.go @@ -64,6 +64,8 @@ func (o *Overlay) IsV110OrLater() bool { } func (o *Overlay) ToString() (string, error) { + o.stabilizeFoldedScalars() + buf := bytes.NewBuffer([]byte{}) decoder := yaml.NewEncoder(buf) decoder.SetIndent(2) diff --git a/yml/foldedscalar.go b/yml/foldedscalar.go new file mode 100644 index 00000000..5e62442d --- /dev/null +++ b/yml/foldedscalar.go @@ -0,0 +1,46 @@ +package yml + +import ( + "strings" + + "gopkg.in/yaml.v3" +) + +// Block indentation is stripped on decode, so leading whitespace means more-indented. +func hasMoreIndentedLine(value string) bool { + for _, line := range strings.Split(value, "\n") { + if line == "" { + continue + } + if line[0] == ' ' || line[0] == '\t' { + return true + } + } + + return false +} + +// StabilizeFoldedScalars restyles folded block scalars that contain a more-indented +// line as literal blocks, leaving every other node untouched. +// +// gopkg.in/yaml.v3 injects a line break before a more-indented line in a folded scalar +// on every encode, so a document that is decoded and re-encoded repeatedly accumulates +// blank lines inside such scalars. The break lands inside the scalar, so it becomes part +// of the decoded value rather than cosmetic whitespace. Literal blocks reproduce their +// value verbatim and round trip unchanged. +// +// Only the representation changes; the decoded value is identical. Call this immediately +// before encoding a node tree. +func StabilizeFoldedScalars(node *yaml.Node) { + if node == nil { + return + } + + if node.Kind == yaml.ScalarNode && node.Style&yaml.FoldedStyle != 0 && hasMoreIndentedLine(node.Value) { + node.Style = node.Style&^yaml.FoldedStyle | yaml.LiteralStyle + } + + for _, child := range node.Content { + StabilizeFoldedScalars(child) + } +} diff --git a/yml/foldedscalar_test.go b/yml/foldedscalar_test.go new file mode 100644 index 00000000..b5044384 --- /dev/null +++ b/yml/foldedscalar_test.go @@ -0,0 +1,251 @@ +package yml_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/yml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// The trailing table row is indented one space further than the rows above it, +// which is what triggers the yaml.v3 emitter defect this file guards against. +const foldedWithMoreIndentedLine = `description: >- + ### Widgets + + | Name | Kind | + | ---- | ---- | + | acme | ` + "`petstore`" + ` | +` + +func roundTrip(t *testing.T, doc string, stabilize bool) string { + t.Helper() + + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(doc), &node)) + + if stabilize { + yml.StabilizeFoldedScalars(&node) + } + + out, err := yaml.Marshal(&node) + require.NoError(t, err) + + return string(out) +} + +func decodeDescription(t *testing.T, doc string) string { + t.Helper() + + var decoded struct { + Description string `yaml:"description"` + } + require.NoError(t, yaml.Unmarshal([]byte(doc), &decoded)) + + return decoded.Description +} + +func TestStabilizeFoldedScalars_SurvivesRepeatedRoundTrips(t *testing.T) { + t.Parallel() + + want := decodeDescription(t, foldedWithMoreIndentedLine) + + doc := foldedWithMoreIndentedLine + for i := range 3 { + doc = roundTrip(t, doc, true) + assert.Equal(t, want, decodeDescription(t, doc), "value changed after %d round trips", i+1) + } +} + +// yaml.v3 does not record the chomping indicator in Node.Style; it re-derives one from +// the trailing newlines of the value. Restyling as a literal block therefore has to keep +// clip and keep chomping intact, not just strip. +func TestStabilizeFoldedScalars_PreservesChomping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + doc string + wantStyle string + }{ + { + name: "strip", + doc: "description: >-\n one line\n more indented\n last\n", + wantStyle: "|-", + }, + { + name: "clip", + doc: "description: >\n one line\n more indented\n last\n", + wantStyle: "|", + }, + { + name: "keep with trailing blank lines", + doc: "description: >+\n one line\n more indented\n\n\n", + wantStyle: "|+", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + want := decodeDescription(t, tt.doc) + + doc := tt.doc + for i := range 3 { + doc = roundTrip(t, doc, true) + assert.Equal(t, want, decodeDescription(t, doc), "value changed after %d round trips", i+1) + } + assert.Contains(t, doc, "description: "+tt.wantStyle+"\n", "chomping should be preserved") + }) + } +} + +func TestStabilizeFoldedScalars_HandlesExplicitTags(t *testing.T) { + t.Parallel() + + doc := "description: !!str >-\n a\n b\n" + want := decodeDescription(t, doc) + + for i := range 3 { + doc = roundTrip(t, doc, true) + assert.Equal(t, want, decodeDescription(t, doc), "value changed after %d round trips", i+1) + } + assert.Contains(t, doc, "!!str", "explicit tag should be preserved") +} + +func TestStabilizeFoldedScalars_LeavesStableStylesAlone(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + doc string + style string + }{ + { + name: "folded scalar with no more-indented line", + doc: "description: >-\n one line\n another line\n", + style: ">-", + }, + { + name: "literal scalar with more-indented line", + doc: "description: |-\n one line\n more indented\n", + style: "|-", + }, + { + name: "plain scalar", + doc: "description: just a string\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + want := decodeDescription(t, tt.doc) + + doc := roundTrip(t, tt.doc, true) + assert.Equal(t, want, decodeDescription(t, doc)) + if tt.style != "" { + assert.Contains(t, doc, tt.style, "original style should be preserved") + } + + for range 3 { + next := roundTrip(t, doc, true) + assert.Equal(t, doc, next, "representation should be a fixed point") + assert.Equal(t, want, decodeDescription(t, next)) + doc = next + } + }) + } +} + +func TestStabilizeFoldedScalars_DetectsMoreIndentedLines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + restyled bool + }{ + {name: "no indentation", value: "one\ntwo", restyled: false}, + {name: "space indented line", value: "one\n two", restyled: true}, + {name: "tab indented line", value: "one\n\ttwo", restyled: true}, + {name: "blank lines only", value: "one\n\ntwo", restyled: false}, + {name: "empty", value: "", restyled: false}, + {name: "first line indented", value: " one\ntwo", restyled: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + node := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Style: yaml.FoldedStyle, + Value: tt.value, + } + + yml.StabilizeFoldedScalars(node) + + want := yaml.FoldedStyle + if tt.restyled { + want = yaml.LiteralStyle + } + assert.Equal(t, want, node.Style) + assert.Equal(t, tt.value, node.Value, "value must not be rewritten") + }) + } +} + +func TestStabilizeFoldedScalars_ToleratesNilNodes(t *testing.T) { + t.Parallel() + + assert.NotPanics(t, func() { yml.StabilizeFoldedScalars(nil) }) + + // A hand-built tree may carry nil children; recursion must not panic. + assert.NotPanics(t, func() { + yml.StabilizeFoldedScalars(&yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{nil, nil}, + }) + }) +} + +// An anchored scalar is an ordinary node in the tree, so aliases pointing at it need +// no special handling: stabilizing the anchor definition covers every reference to it. +func TestStabilizeFoldedScalars_HandlesAnchoredScalars(t *testing.T) { + t.Parallel() + + doc := "a: &anchor >-\n one\n more indented\nb: *anchor\n" + + var want struct { + A string `yaml:"a"` + B string `yaml:"b"` + } + require.NoError(t, yaml.Unmarshal([]byte(doc), &want)) + + for i := range 3 { + doc = roundTrip(t, doc, true) + + var got struct { + A string `yaml:"a"` + B string `yaml:"b"` + } + require.NoError(t, yaml.Unmarshal([]byte(doc), &got)) + assert.Equal(t, want, got, "value changed after %d round trips", i+1) + assert.Contains(t, doc, "*anchor", "alias should be preserved") + } +} + +// Fails once gopkg.in/yaml.v3 fixes the emitter, at which point StabilizeFoldedScalars can go. +func TestFoldedScalarGrowsWithoutStabilizer(t *testing.T) { + t.Parallel() + + before := decodeDescription(t, foldedWithMoreIndentedLine) + after := decodeDescription(t, roundTrip(t, foldedWithMoreIndentedLine, false)) + + assert.NotEqual(t, before, after) +}