Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions marshaller/coremodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions openapi/foldedscalar_marshalling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package openapi_test

import (
"bytes"
"context"
"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 := context.Background()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
ctx := context.Background()
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 30 {
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)
}
}
3 changes: 3 additions & 0 deletions openapi/localize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -581,6 +582,8 @@ func rewriteInternalReferences(content []byte, originalRef string, storage *loca
}

// Marshal back to YAML
yml.StabilizeFoldedScalars(&node)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be worth adding a test fixture with a more-indented string in https://github.com/speakeasy-api/openapi/tree/main/openapi/testdata/localize


updatedContent, err := yaml.Marshal(&node)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated YAML: %w", err)
Expand Down
6 changes: 6 additions & 0 deletions overlay/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -30,6 +31,8 @@ func (o *Overlay) ApplyTo(root *yaml.Node) error {
}
}

yml.StabilizeFoldedScalars(root)

return nil
}

Expand Down Expand Up @@ -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
}

Expand Down
17 changes: 17 additions & 0 deletions overlay/foldedscalar.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
yml.StabilizeFoldedScalars(&o.Actions[i].Update)
}
}
158 changes: 158 additions & 0 deletions overlay/foldedscalar_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
t.Parallel()

o := testOverlay()

doc := "title: Original\n" + foldedWithMoreIndentedLine
want := decodeDescription(t, doc)

for i := range 30 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i don't think 30 iterations is necessary (multiple instances)

Suggested change
for i := range 30 {
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 30 {
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.
func TestFormatSurvivesRepeatedRoundTrips(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name is slightly misleading as it invokes ToString, not Format. I think this test should exercise both of these methods (like it's done in TestFormatToleratesNilOverlay)

t.Parallel()

src := `overlay: 1.0.0
info:
title: Test
version: 1.0.0
actions:
- target: $.info
update:
` + indent(foldedWithMoreIndentedLine, " ")

want := updateDescription(t, src)

doc := src
for i := range 30 {
o, err := overlay.ParseReader(strings.NewReader(doc))
require.NoError(t, err)

formatted, err := o.ToString()
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
}
2 changes: 2 additions & 0 deletions overlay/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions overlay/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions yml/foldedscalar.go
Original file line number Diff line number Diff line change
@@ -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 {

@cubic-dev-ai cubic-dev-ai Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the input is an alias or a subtree whose folded scalar is reachable only through Alias, this traversal never stabilizes the target, so repeated encode/decode cycles can still accumulate blank lines. Traverse alias targets with a visited set to avoid recursing indefinitely through YAML alias cycles.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At yml/foldedscalar.go, line 43:

<comment>When the input is an alias or a subtree whose folded scalar is reachable only through `Alias`, this traversal never stabilizes the target, so repeated encode/decode cycles can still accumulate blank lines. Traverse alias targets with a visited set to avoid recursing indefinitely through YAML alias cycles.</comment>

<file context>
@@ -0,0 +1,46 @@
+		node.Style = node.Style&^yaml.FoldedStyle | yaml.LiteralStyle
+	}
+
+	for _, child := range node.Content {
+		StabilizeFoldedScalars(child)
+	}
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reachable in practice, so leaving the traversal as is.

An anchor definition is an ordinary node in the tree — &anchor >- is a scalar node sitting in its parent's Content, carrying an Anchor value — so the existing walk already visits and stabilizes it. An alias node adds no new content; it points back at that same node, and the encoder emits *anchor for it. A document such as:

a: &anchor >-
  one
   more indented
b: *anchor

is a fixed point after 30 encode/decode cycles under this implementation. Added as TestStabilizeFoldedScalars_HandlesAnchoredScalars in 37c7fb9 so it stays that way.

The only shape where a folded scalar is reachable solely through Alias is a bare alias node handed in as the root of a subtree whose anchor is defined outside it — and encoding that subtree emits *anchor with no anchor definition, i.e. invalid YAML regardless of styling. Recursing into Alias would also need a visited set to stay safe on hand-built cyclic trees, which is complexity for a case a decoded document cannot produce.

StabilizeFoldedScalars(child)
}
}
Loading