diff --git a/compilers/openapi/cycles_test.go b/compilers/openapi/cycles_test.go index 4a18066e..40e1d0e6 100644 --- a/compilers/openapi/cycles_test.go +++ b/compilers/openapi/cycles_test.go @@ -1,9 +1,7 @@ package openapi import ( - "fmt" "os" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -12,6 +10,7 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/scan" + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" "github.com/dexpace/morphic/ir" ) @@ -214,24 +213,10 @@ func readReproducer(t *testing.T, file string) []byte { return data } -func mergeChainSpec(levels int) string { - var b strings.Builder - b.WriteString("openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\nx-anchors:\n") - b.WriteString(" m0: &m0 {type: object}\n") - for i := 1; i <= levels; i++ { - fmt.Fprintf(&b, " m%d: &m%d {<<: *m%d, p%d: %d}\n", i, i, i-1, i, i) - } - b.WriteString("components:\n schemas:\n") - for i := levels; i >= 0; i-- { - fmt.Fprintf(&b, " S%d: {properties: {x: *m%d}}\n", i, i) - } - return b.String() -} - func TestCompile_MergeChainPastBoundStillCompiles(t *testing.T) { t.Parallel() doc, diags, err := New().Compile(t.Context(), - []compilers.Source{{Path: "deep-merge.yaml", Data: []byte(mergeChainSpec(200))}}, + []compilers.Source{{Path: "deep-merge.yaml", Data: []byte(ynode.MergeChainSpec(200))}}, compilers.Options{}) require.NoError(t, err) require.NotNil(t, doc, "a legal document is still compiled") diff --git a/compilers/openapi/internal/nodeview/nodeview.go b/compilers/openapi/internal/nodeview/nodeview.go index dc1e0660..d263aa04 100644 --- a/compilers/openapi/internal/nodeview/nodeview.go +++ b/compilers/openapi/internal/nodeview/nodeview.go @@ -16,6 +16,7 @@ import ( yaml "gopkg.in/yaml.v3" "github.com/dexpace/morphic/compilers/openapi/internal/ids" + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" ) // maxAliasChain bounds how many alias hops Deref follows. yaml.v3 resolves an @@ -270,10 +271,6 @@ func (v *View) mergeSource(val *yaml.Node, depth int) ([]Pair, bool) { return dedupeFirstWins(out), complete } -// MergeTag is the tag yaml.v3 resolves every `<<` merge key to, and the exact -// tag speakeasy's yml.IsMergeKey requires before treating one as a merge. -const MergeTag = "!!merge" - // IsMergeKey reports whether a raw mapping key node is a `<<` merge key, // applying the same test speakeasy does: yml.IsMergeKey (yml/yml.go), run over // every mapping via yml.ResolveMergeKeys. The key is checked undereferenced (an @@ -288,7 +285,7 @@ const MergeTag = "!!merge" // reachable from a parsed document today, but re-check this against // yml.IsMergeKey on any dependency bump. func IsMergeKey(n *yaml.Node) bool { - return n != nil && n.Kind == yaml.ScalarNode && n.Value == "<<" && n.Tag == MergeTag + return n != nil && n.Kind == yaml.ScalarNode && n.Value == "<<" && n.Tag == ynode.MergeTag } // dedupeFirstWins keeps only the first pair for each key, preserving order — the diff --git a/compilers/openapi/internal/nodeview/nodeview_internal_test.go b/compilers/openapi/internal/nodeview/nodeview_internal_test.go index de8affb1..d0da54ab 100644 --- a/compilers/openapi/internal/nodeview/nodeview_internal_test.go +++ b/compilers/openapi/internal/nodeview/nodeview_internal_test.go @@ -7,31 +7,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" -) - -func yscalar(v string) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Value: v} -} - -func ymap(pairs ...*yaml.Node) *yaml.Node { - return &yaml.Node{Kind: yaml.MappingNode, Content: pairs} -} - -func yseq(items ...*yaml.Node) *yaml.Node { - return &yaml.Node{Kind: yaml.SequenceNode, Content: items} -} -func yalias(target *yaml.Node) *yaml.Node { - return &yaml.Node{Kind: yaml.AliasNode, Alias: target} -} - -func ymerge() *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Value: "<<", Tag: MergeTag} -} + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" +) func TestDocumentRoot_Cases(t *testing.T) { t.Parallel() - content := yscalar("x") + content := ynode.Scalar("x") tests := []struct { name string in *yaml.Node @@ -62,13 +44,13 @@ func TestChildByToken_NilNode(t *testing.T) { func TestPointerPath_Cases(t *testing.T) { t.Parallel() - leaf := yscalar("leaf") - target := ymap(yscalar("b"), leaf) - root := ymap( - yscalar("arr"), yseq(yscalar("zero"), yscalar("one")), - yscalar("via"), yalias(target), - yscalar("a/b"), yscalar("slash"), - yscalar("c~d"), yscalar("tilde"), + leaf := ynode.Scalar("leaf") + target := ynode.Map(ynode.Scalar("b"), leaf) + root := ynode.Map( + ynode.Scalar("arr"), ynode.Seq(ynode.Scalar("zero"), ynode.Scalar("one")), + ynode.Scalar("via"), ynode.Alias(target), + ynode.Scalar("a/b"), ynode.Scalar("slash"), + ynode.Scalar("c~d"), ynode.Scalar("tilde"), ) tests := []struct { name string @@ -125,23 +107,11 @@ func TestInternalPointer_MatchesTheResolversNormalization(t *testing.T) { func TestDeref_FollowsAliasChain(t *testing.T) { t.Parallel() - target := ymap(yscalar("k"), yscalar("v")) - require.Same(t, target, Deref(yalias(target))) + target := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) + require.Same(t, target, Deref(ynode.Alias(target))) require.Same(t, target, Deref(target)) } -func mergeChain(levels int) *yaml.Node { - nodes := make([]*yaml.Node, levels+1) - for i := range nodes { - nodes[i] = &yaml.Node{Kind: yaml.MappingNode} - } - for i := range levels { - nodes[i].Content = []*yaml.Node{ymerge(), yalias(nodes[i+1])} - } - nodes[levels].Content = []*yaml.Node{yscalar("leaf"), yscalar("v")} - return nodes[0] -} - func pairMap(pairs []Pair) map[string]string { out := make(map[string]string, len(pairs)) for _, p := range pairs { @@ -160,14 +130,14 @@ func TestIsMergeKey_MatchesResolver(t *testing.T) { in *yaml.Node want bool }{ - {"resolved merge tag", tagged(MergeTag), true}, + {"resolved merge tag", tagged(ynode.MergeTag), true}, {"quoted string tag", tagged("!!str"), false}, - {"untagged scalar", yscalar("<<"), false}, + {"untagged scalar", ynode.Scalar("<<"), false}, {"non-specific tag", tagged("!"), false}, {"long-form merge tag", tagged("tag:yaml.org,2002:merge"), false}, - {"other value", yscalar("$ref"), false}, - {"alias key", yalias(ymerge()), false}, - {"mapping key", ymap(), false}, + {"other value", ynode.Scalar("$ref"), false}, + {"alias key", ynode.Alias(ynode.Merge()), false}, + {"mapping key", ynode.Map(), false}, {"nil", nil, false}, } for _, tc := range tests { @@ -218,7 +188,7 @@ func TestPureRefTarget_Cases(t *testing.T) { t.Run("non-mapping node has no target", func(t *testing.T) { t.Parallel() - _, ok := New().PureRefTarget(yscalar("x")) + _, ok := New().PureRefTarget(ynode.Scalar("x")) assert.False(t, ok) }) @@ -227,12 +197,12 @@ func TestPureRefTarget_Cases(t *testing.T) { n *yaml.Node want string }{ - {"sibling key before the ref", ymap(yscalar("type"), yscalar("object"), - yscalar("$ref"), yscalar("#/components/schemas/A")), "/components/schemas/A"}, - {"external ref is not internal", ymap(yscalar("$ref"), yscalar("other.yaml#/A")), ""}, - {"non-scalar ref value", ymap(yscalar("$ref"), ymap(yscalar("a"), yscalar("b"))), ""}, - {"nil ref value via broken alias", ymap(yscalar("$ref"), yalias(nil)), ""}, - {"no ref key at all", ymap(yscalar("type"), yscalar("object")), ""}, + {"sibling key before the ref", ynode.Map(ynode.Scalar("type"), ynode.Scalar("object"), + ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/A")), "/components/schemas/A"}, + {"external ref is not internal", ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("other.yaml#/A")), ""}, + {"non-scalar ref value", ynode.Map(ynode.Scalar("$ref"), ynode.Map(ynode.Scalar("a"), ynode.Scalar("b"))), ""}, + {"nil ref value via broken alias", ynode.Map(ynode.Scalar("$ref"), ynode.Alias(nil)), ""}, + {"no ref key at all", ynode.Map(ynode.Scalar("type"), ynode.Scalar("object")), ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -246,15 +216,15 @@ func TestPureRefTarget_Cases(t *testing.T) { func TestChildByToken_MappingResolvesThroughAliasKey(t *testing.T) { t.Parallel() - keyTarget := yscalar("k") - val := yscalar("v") - n := ymap(yalias(keyTarget), val) + keyTarget := ynode.Scalar("k") + val := ynode.Scalar("v") + n := ynode.Map(ynode.Alias(keyTarget), val) assert.Same(t, val, New().ChildByToken(n, "k")) } func TestChildByToken_ScalarNodeHasNoChild(t *testing.T) { t.Parallel() - assert.Nil(t, New().ChildByToken(yscalar("x"), "0")) + assert.Nil(t, New().ChildByToken(ynode.Scalar("x"), "0")) } func TestNodeView_CachesOnlyReproducibleExpansions(t *testing.T) { @@ -262,8 +232,8 @@ func TestNodeView_CachesOnlyReproducibleExpansions(t *testing.T) { t.Run("complete expansion is cached", func(t *testing.T) { t.Parallel() - base := ymap(yscalar("a"), yscalar("1")) - n := ymap(ymerge(), yalias(base), yscalar("b"), yscalar("2")) + base := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) + n := ynode.Map(ynode.Merge(), ynode.Alias(base), ynode.Scalar("b"), ynode.Scalar("2")) v := New() first := v.MappingPairs(n) require.Contains(t, v.pairs, n, "a complete expansion is memoized") @@ -279,9 +249,9 @@ func TestNodeView_CachesOnlyReproducibleExpansions(t *testing.T) { outer = &yaml.Node{Kind: yaml.MappingNode} shared = &yaml.Node{Kind: yaml.MappingNode} deep = &yaml.Node{Kind: yaml.MappingNode} - outer.Content = []*yaml.Node{yscalar("outerkey"), yscalar("o"), ymerge(), yalias(shared)} - shared.Content = []*yaml.Node{yscalar("keep"), yscalar("v"), ymerge(), yalias(deep)} - deep.Content = []*yaml.Node{yscalar("deepkey"), yscalar("d"), ymerge(), yalias(outer)} + outer.Content = []*yaml.Node{ynode.Scalar("outerkey"), ynode.Scalar("o"), ynode.Merge(), ynode.Alias(shared)} + shared.Content = []*yaml.Node{ynode.Scalar("keep"), ynode.Scalar("v"), ynode.Merge(), ynode.Alias(deep)} + deep.Content = []*yaml.Node{ynode.Scalar("deepkey"), ynode.Scalar("d"), ynode.Merge(), ynode.Alias(outer)} return outer, shared, deep } @@ -313,7 +283,7 @@ func TestNodeView_CachesOnlyReproducibleExpansions(t *testing.T) { t.Parallel() outer, _, _ := mergeCycle() v := New() - other := ymap(yscalar("k"), yscalar("v")) + other := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) v.inFlight[other] = true // as if this read came from inside other's assert.NotEmpty(t, v.MappingPairs(outer), "the read still answers") @@ -325,25 +295,25 @@ func TestNodeView_CachesOnlyReproducibleExpansions(t *testing.T) { func TestNodeView_TruncationIsPerNode(t *testing.T) { t.Parallel() v := New() - require.Empty(t, v.MappingPairs(mergeChain(MergeDepthLimit+2))) + require.Empty(t, v.MappingPairs(ynode.MergeChain(MergeDepthLimit+2))) require.True(t, v.exhausted) - other := ymap(yscalar("$ref"), yscalar("#/components/schemas/A")) + other := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/A")) assert.Equal(t, map[string]string{"$ref": "#/components/schemas/A"}, pairMap(v.MappingPairs(other)), "an unrelated mapping still expands in full") assert.Equal(t, map[string]string{"leaf": "v"}, - pairMap(v.MappingPairs(mergeChain(MergeDepthLimit))), + pairMap(v.MappingPairs(ynode.MergeChain(MergeDepthLimit))), "so does a chain that fits inside the bound") } func TestNodeView_MemoizeRespectsPairBudget(t *testing.T) { t.Parallel() - pairs := []Pair{{Key: "a", Val: yscalar("1")}, {Key: "b", Val: yscalar("2")}} + pairs := []Pair{{Key: "a", Val: ynode.Scalar("1")}, {Key: "b", Val: ynode.Scalar("2")}} t.Run("within budget: retained and counted", func(t *testing.T) { t.Parallel() v := New() - n := ymap() + n := ynode.Map() v.memoize(n, pairs) assert.Contains(t, v.pairs, n) assert.Equal(t, len(pairs), v.cachedPairs) @@ -353,7 +323,7 @@ func TestNodeView_MemoizeRespectsPairBudget(t *testing.T) { t.Parallel() v := New() v.cachedPairs = maxCachedPairs - 1 - n := ymap() + n := ynode.Map() v.memoize(n, pairs) assert.NotContains(t, v.pairs, n, "an entry that would overrun the budget is not kept") assert.Equal(t, maxCachedPairs-1, v.cachedPairs, "and does not count against it") @@ -361,8 +331,8 @@ func TestNodeView_MemoizeRespectsPairBudget(t *testing.T) { t.Run("a dropped entry still reads correctly", func(t *testing.T) { t.Parallel() - base := ymap(yscalar("a"), yscalar("1")) - n := ymap(ymerge(), yalias(base), yscalar("b"), yscalar("2")) + base := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) + n := ynode.Map(ynode.Merge(), ynode.Alias(base), ynode.Scalar("b"), ynode.Scalar("2")) v := New() v.cachedPairs = maxCachedPairs want := map[string]string{"a": "1", "b": "2"} @@ -438,12 +408,12 @@ func TestExhausted_ReportsAnIncompleteExpansion(t *testing.T) { v := New() assert.False(t, v.Exhausted(), "a view that has expanded nothing has exhausted nothing") - deep := mergeChain(MergeDepthLimit + 2) + deep := ynode.MergeChain(MergeDepthLimit + 2) _ = v.MappingPairs(deep) assert.True(t, v.Exhausted(), "past the bound the view says its expansion is incomplete") shallow := New() - _ = shallow.MappingPairs(mergeChain(2)) + _ = shallow.MappingPairs(ynode.MergeChain(2)) assert.False(t, shallow.Exhausted(), "within the bound it does not") } @@ -457,9 +427,9 @@ func yamlDoc(t *testing.T, src string) *yaml.Node { func TestPointerPath_KeepsTheNodesTheWalkPassesThrough(t *testing.T) { t.Parallel() - leaf := yscalar("leaf") - inner := ymap(yscalar("b"), leaf) - root := ymap(yscalar("a"), inner) + leaf := ynode.Scalar("leaf") + inner := ynode.Map(ynode.Scalar("b"), leaf) + root := ynode.Map(ynode.Scalar("a"), inner) path, complete := New().PointerPath(root, "/a/b") require.True(t, complete, "every token resolves") @@ -469,8 +439,8 @@ func TestPointerPath_KeepsTheNodesTheWalkPassesThrough(t *testing.T) { func TestPointerPath_IncompleteStopsAtTheLastNodeReached(t *testing.T) { t.Parallel() - inner := ymap(yscalar("b"), yscalar("leaf")) - root := ymap(yscalar("a"), inner) + inner := ynode.Map(ynode.Scalar("b"), ynode.Scalar("leaf")) + root := ynode.Map(ynode.Scalar("a"), inner) path, complete := New().PointerPath(root, "/a/missing/deeper") assert.False(t, complete, "a token that names nothing stops the walk") @@ -480,7 +450,7 @@ func TestPointerPath_IncompleteStopsAtTheLastNodeReached(t *testing.T) { func TestPointerPath_RootTokenlessAndNil(t *testing.T) { t.Parallel() - root := ymap(yscalar("a"), yscalar("v")) + root := ynode.Map(ynode.Scalar("a"), ynode.Scalar("v")) path, complete := New().PointerPath(root, "") assert.True(t, complete, "a pointer with no tokens names the root") @@ -496,7 +466,7 @@ func TestPointerPath_SegmentCapStopsTheWalk(t *testing.T) { // A mapping whose only key is "a" and whose value is itself cannot be built // from parsed YAML, but an alias can stand in: the walk follows "a" as long // as tokens last, so only the cap can end it. - root := ymap(yscalar("a"), nil) + root := ynode.Map(ynode.Scalar("a"), nil) root.Content[1] = root ref := strings.Repeat("/a", maxPointerSegments+1) diff --git a/compilers/openapi/internal/scan/amplification_internal_test.go b/compilers/openapi/internal/scan/amplification_internal_test.go index da7d6900..bc143aeb 100644 --- a/compilers/openapi/internal/scan/amplification_internal_test.go +++ b/compilers/openapi/internal/scan/amplification_internal_test.go @@ -12,6 +12,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" "github.com/dexpace/morphic/ir" ) @@ -208,9 +209,9 @@ func TestComputeAllowance_TakesTheLesserBound(t *testing.T) { } func aliasFanOutNode(levels int) *yaml.Node { - cur := ymap(yscalar("type"), yscalar("string")) + cur := ynode.Map(ynode.Scalar("type"), ynode.Scalar("string")) for range levels { - cur = ymap(yscalar("allOf"), yseq(yalias(cur), yalias(cur))) + cur = ynode.Map(ynode.Scalar("allOf"), ynode.Seq(ynode.Alias(cur), ynode.Alias(cur))) } return cur } @@ -240,10 +241,10 @@ func TestAliasWeigher_NilRoot(t *testing.T) { func TestExpandedWeight_NoAliasesEqualsRawCount(t *testing.T) { t.Parallel() - root := ymap( - yscalar("a"), yscalar("1"), - yscalar("b"), yseq(yscalar("x"), yscalar("y"), ymap(yscalar("c"), yscalar("2"))), - yscalar("d"), ymap(yscalar("e"), yscalar("3"), yscalar("f"), yscalar("4")), + root := ynode.Map( + ynode.Scalar("a"), ynode.Scalar("1"), + ynode.Scalar("b"), ynode.Seq(ynode.Scalar("x"), ynode.Scalar("y"), ynode.Map(ynode.Scalar("c"), ynode.Scalar("2"))), + ynode.Scalar("d"), ynode.Map(ynode.Scalar("e"), ynode.Scalar("3"), ynode.Scalar("f"), ynode.Scalar("4")), ) raw := rawNodeCount(root) @@ -255,13 +256,13 @@ func TestExpandedWeight_NoAliasesEqualsRawCount(t *testing.T) { func TestAliasWeigher_AliasToSubtreeMultiplies(t *testing.T) { t.Parallel() - base := ymap(yscalar("a"), yscalar("1")) // weight 3: itself, one key, one value + base := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) // weight 3: itself, one key, one value const reuses = 5 aliases := make([]*yaml.Node, reuses) for i := range aliases { - aliases[i] = yalias(base) + aliases[i] = ynode.Alias(base) } - root := ymap(yscalar("k"), yseq(aliases...)) + root := ynode.Map(ynode.Scalar("k"), ynode.Seq(aliases...)) w := newAliasWeigher(1000) _, exceeded := w.weigh(root) @@ -273,13 +274,13 @@ func TestAliasWeigher_AliasToSubtreeMultiplies(t *testing.T) { func TestAliasWeigher_MemoizesSharedTarget(t *testing.T) { t.Parallel() - base := ymap(yscalar("a"), yscalar("1")) + base := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) const reuses = 25 aliases := make([]*yaml.Node, reuses) for i := range aliases { - aliases[i] = yalias(base) + aliases[i] = ynode.Alias(base) } - root := ymap(yscalar("k"), yseq(aliases...)) + root := ynode.Map(ynode.Scalar("k"), ynode.Seq(aliases...)) w := newAliasWeigher(1_000_000) _, exceeded := w.weigh(root) @@ -314,14 +315,14 @@ func TestChildrenOf_AliasWithoutTarget(t *testing.T) { orphan := &yaml.Node{Kind: yaml.AliasNode} assert.Nil(t, childrenOf(orphan), "an alias with no target depends on nothing") - pair := ymap(yscalar("k"), yscalar("v")) + pair := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) assert.Equal(t, pair.Content, childrenOf(pair), "every other node depends on its own Content") } func TestAliasWeigher_AliasWithoutTargetWeighsZero(t *testing.T) { t.Parallel() orphan := &yaml.Node{Kind: yaml.AliasNode} - root := ymap(yscalar("k"), orphan) + root := ynode.Map(ynode.Scalar("k"), orphan) w := newAliasWeigher(1000) _, exceeded := w.weigh(root) @@ -332,7 +333,7 @@ func TestAliasWeigher_AliasWithoutTargetWeighsZero(t *testing.T) { func TestAliasWeigher_NilChildIsSkipped(t *testing.T) { t.Parallel() - root := ymap(yscalar("k"), yscalar("v")) + root := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) root.Content = append(root.Content, nil) w := newAliasWeigher(1000) @@ -343,8 +344,8 @@ func TestAliasWeigher_NilChildIsSkipped(t *testing.T) { func TestAliasWeigher_InFlightCycleSaturates(t *testing.T) { t.Parallel() - loop := ymap(yscalar("k"), yscalar("v")) - loop.Content = append(loop.Content, yalias(loop)) // loop's own subtree aliases loop + loop := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) + loop.Content = append(loop.Content, ynode.Alias(loop)) // loop's own subtree aliases loop w := newAliasWeigher(1000) culprit, exceeded := w.weigh(loop) diff --git a/compilers/openapi/internal/scan/scan_internal_test.go b/compilers/openapi/internal/scan/scan_internal_test.go index 04fa3f4a..c3bf3fbf 100644 --- a/compilers/openapi/internal/scan/scan_internal_test.go +++ b/compilers/openapi/internal/scan/scan_internal_test.go @@ -14,6 +14,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/nodeview" + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" "github.com/dexpace/morphic/ir" ) @@ -226,26 +227,6 @@ func readReproducer(t *testing.T, file string) []byte { return data } -func yscalar(v string) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Value: v} -} - -func ymap(pairs ...*yaml.Node) *yaml.Node { - return &yaml.Node{Kind: yaml.MappingNode, Content: pairs} -} - -func yseq(items ...*yaml.Node) *yaml.Node { - return &yaml.Node{Kind: yaml.SequenceNode, Content: items} -} - -func yalias(target *yaml.Node) *yaml.Node { - return &yaml.Node{Kind: yaml.AliasNode, Alias: target} -} - -func ymerge() *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Value: "<<", Tag: nodeview.MergeTag} -} - func TestRecoverCycleScan_PanicYieldsWarning(t *testing.T) { t.Parallel() got := recoverCycleScan(3, func() []ir.Diagnostic { @@ -288,7 +269,7 @@ func TestDetectCycles_LegalAliasReuseClean(t *testing.T) { func TestAnchorName_Cases(t *testing.T) { t.Parallel() anchored := &yaml.Node{Kind: yaml.MappingNode, Anchor: "root"} - assert.Equal(t, "root", anchorName(yalias(anchored))) + assert.Equal(t, "root", anchorName(ynode.Alias(anchored))) assert.Equal(t, "bare", anchorName(&yaml.Node{Kind: yaml.AliasNode, Value: "bare"})) } @@ -314,15 +295,15 @@ func TestFollowRefChain_DepthCapReturnsFalse(t *testing.T) { t.Parallel() const n = maxCycleDepth + 2 schemas := &yaml.Node{Kind: yaml.MappingNode} - root := ymap(yscalar("schemas"), schemas) + root := ynode.Map(ynode.Scalar("schemas"), schemas) nodes := make([]*yaml.Node, n) for i := range nodes { nodes[i] = &yaml.Node{Kind: yaml.MappingNode} } for i := range nodes { - schemas.Content = append(schemas.Content, yscalar(strconv.Itoa(i)), nodes[i]) + schemas.Content = append(schemas.Content, ynode.Scalar(strconv.Itoa(i)), nodes[i]) if i < n-1 { - nodes[i].Content = []*yaml.Node{yscalar("$ref"), yscalar("#/schemas/" + strconv.Itoa(i+1))} + nodes[i].Content = []*yaml.Node{ynode.Scalar("$ref"), ynode.Scalar("#/schemas/" + strconv.Itoa(i+1))} } } verdict, _ := newRefScan().followRefChain(root, nodes[0]) @@ -332,10 +313,10 @@ func TestFollowRefChain_DepthCapReturnsFalse(t *testing.T) { func TestFollowRefChain_SafeMemoShortCircuits(t *testing.T) { t.Parallel() - a := ymap(yscalar("$ref"), yscalar("#/schemas/B")) - b := ymap(yscalar("$ref"), yscalar("#/schemas/A")) - schemas := ymap(yscalar("A"), a, yscalar("B"), b) - root := ymap(yscalar("schemas"), schemas) + a := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/schemas/B")) + b := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/schemas/A")) + schemas := ynode.Map(ynode.Scalar("A"), a, ynode.Scalar("B"), b) + root := ynode.Map(ynode.Scalar("schemas"), schemas) verdict, _ := newRefScan().followRefChain(root, a) assert.Equal(t, chainCycles, verdict, "A -> B -> A is cyclic with an empty memo") @@ -349,8 +330,8 @@ func TestFollowRefChain_SafeMemoShortCircuits(t *testing.T) { func TestFollowRefChain_DanglingRefIsNotCycle(t *testing.T) { t.Parallel() - a := ymap(yscalar("$ref"), yscalar("#/schemas/Missing")) - root := ymap(yscalar("schemas"), ymap(yscalar("A"), a)) + a := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/schemas/Missing")) + root := ynode.Map(ynode.Scalar("schemas"), ynode.Map(ynode.Scalar("A"), a)) s := newRefScan() verdict, _ := s.followRefChain(root, a) assert.Equal(t, chainTerminates, verdict, "a dangling $ref is not a cycle") @@ -366,28 +347,28 @@ func TestMappingPairs_Cases(t *testing.T) { want map[string]string }{ {"nil node yields no pairs", nil, nil}, - {"non-mapping node yields no pairs", yscalar("x"), nil}, + {"non-mapping node yields no pairs", ynode.Scalar("x"), nil}, { "alias-valued mapping is dereferenced at entry", - yalias(ymap(yscalar("k"), yscalar("v"))), + ynode.Alias(ynode.Map(ynode.Scalar("k"), ynode.Scalar("v"))), map[string]string{"k": "v"}, }, { "alias key and alias value are dereferenced", - ymap(yalias(yscalar("k")), yalias(yscalar("v"))), + ynode.Map(ynode.Alias(ynode.Scalar("k")), ynode.Alias(ynode.Scalar("v"))), map[string]string{"k": "v"}, }, { "non-scalar key after nodeview.Deref is skipped", - ymap( - ymap(yscalar("x"), yscalar("1")), yscalar("ignored"), - yscalar("real"), yscalar("kept"), + ynode.Map( + ynode.Map(ynode.Scalar("x"), ynode.Scalar("1")), ynode.Scalar("ignored"), + ynode.Scalar("real"), ynode.Scalar("kept"), ), map[string]string{"real": "kept"}, }, { "key aliasing a nil target is skipped", - ymap(yalias(nil), yscalar("ignored"), yscalar("real"), yscalar("kept")), + ynode.Map(ynode.Alias(nil), ynode.Scalar("ignored"), ynode.Scalar("real"), ynode.Scalar("kept")), map[string]string{"real": "kept"}, }, { @@ -397,35 +378,35 @@ func TestMappingPairs_Cases(t *testing.T) { }, { "duplicate explicit Key: last wins", - ymap(yscalar("k"), yscalar("first"), yscalar("k"), yscalar("second")), + ynode.Map(ynode.Scalar("k"), ynode.Scalar("first"), ynode.Scalar("k"), ynode.Scalar("second")), map[string]string{"k": "second"}, }, { "a merged key still yields to a repeated explicit key", - ymap(yscalar("k"), yscalar("first"), ymerge(), ymap(yscalar("k"), yscalar("from-merge")), - yscalar("k"), yscalar("second")), + ynode.Map(ynode.Scalar("k"), ynode.Scalar("first"), ynode.Merge(), ynode.Map(ynode.Scalar("k"), ynode.Scalar("from-merge")), + ynode.Scalar("k"), ynode.Scalar("second")), map[string]string{"k": "second"}, }, { "merge key contributes a mapping's pairs", - ymap(ymerge(), yalias(ymap(yscalar("a"), yscalar("1"))), yscalar("b"), yscalar("2")), + ynode.Map(ynode.Merge(), ynode.Alias(ynode.Map(ynode.Scalar("a"), ynode.Scalar("1"))), ynode.Scalar("b"), ynode.Scalar("2")), map[string]string{"a": "1", "b": "2"}, }, { "merge value that is not a mapping contributes nothing", - ymap(ymerge(), yscalar("not-a-mapping"), yscalar("b"), yscalar("2")), + ynode.Map(ynode.Merge(), ynode.Scalar("not-a-mapping"), ynode.Scalar("b"), ynode.Scalar("2")), map[string]string{"b": "2"}, }, { "explicit key wins over merged key", - ymap(yscalar("a"), yscalar("explicit"), ymerge(), ymap(yscalar("a"), yscalar("from-merge"))), + ynode.Map(ynode.Scalar("a"), ynode.Scalar("explicit"), ynode.Merge(), ynode.Map(ynode.Scalar("a"), ynode.Scalar("from-merge"))), map[string]string{"a": "explicit"}, }, { "merge sequence: earlier source wins on a shared key", - ymap(ymerge(), yseq( - ymap(yscalar("a"), yscalar("from-first")), - ymap(yscalar("a"), yscalar("from-second"), yscalar("b"), yscalar("only-in-second")), + ynode.Map(ynode.Merge(), ynode.Seq( + ynode.Map(ynode.Scalar("a"), ynode.Scalar("from-first")), + ynode.Map(ynode.Scalar("a"), ynode.Scalar("from-second"), ynode.Scalar("b"), ynode.Scalar("only-in-second")), )), map[string]string{"a": "from-first", "b": "only-in-second"}, }, @@ -458,10 +439,10 @@ func TestMappingPairs_Cases(t *testing.T) { t.Run("duplicate explicit key keeps the last occurrence's position", func(t *testing.T) { t.Parallel() - n := ymap( - yscalar("k"), yscalar("first"), - yscalar("other"), yscalar("o"), - yscalar("k"), yscalar("second"), + n := ynode.Map( + ynode.Scalar("k"), ynode.Scalar("first"), + ynode.Scalar("other"), ynode.Scalar("o"), + ynode.Scalar("k"), ynode.Scalar("second"), ) got := nodeview.New().MappingPairs(n) require.Len(t, got, 2) @@ -473,7 +454,7 @@ func TestMappingPairs_Cases(t *testing.T) { t.Run("merge chain at the depth bound still reaches the leaf", func(t *testing.T) { t.Parallel() v := nodeview.New() - got := v.MappingPairs(mergeChain(nodeview.MergeDepthLimit)) + got := v.MappingPairs(ynode.MergeChain(nodeview.MergeDepthLimit)) assert.Equal(t, map[string]string{"leaf": "v"}, pairMap(got), "a chain exactly at the bound expands in full") assert.False(t, v.Exhausted(), "expanding to the bound is not exceeding it") @@ -482,41 +463,29 @@ func TestMappingPairs_Cases(t *testing.T) { t.Run("merge chain past the depth bound stops at the bound", func(t *testing.T) { t.Parallel() v := nodeview.New() - assert.Empty(t, v.MappingPairs(mergeChain(nodeview.MergeDepthLimit+2)), + assert.Empty(t, v.MappingPairs(ynode.MergeChain(nodeview.MergeDepthLimit+2)), "a merge chain longer than the bound never reaches the leaf pair") assert.True(t, v.Exhausted(), "exceeding the bound is recorded for refCycles") }) } func trailingKeyNode() *yaml.Node { - n := ymap(yscalar("a"), yscalar("1")) - n.Content = append(n.Content, yscalar("dangling")) + n := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) + n.Content = append(n.Content, ynode.Scalar("dangling")) return n } func mergeSeqReuseNode() *yaml.Node { - base := ymap(yscalar("a"), yscalar("1")) - return ymap(ymerge(), yseq(yalias(base), yalias(base))) + base := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) + return ynode.Map(ynode.Merge(), ynode.Seq(ynode.Alias(base), ynode.Alias(base))) } func selfReferentialMergeNode() *yaml.Node { n := &yaml.Node{Kind: yaml.MappingNode} - n.Content = []*yaml.Node{ymerge(), yalias(n)} + n.Content = []*yaml.Node{ynode.Merge(), ynode.Alias(n)} return n } -func mergeChain(levels int) *yaml.Node { - nodes := make([]*yaml.Node, levels+1) - for i := range nodes { - nodes[i] = &yaml.Node{Kind: yaml.MappingNode} - } - for i := range levels { - nodes[i].Content = []*yaml.Node{ymerge(), yalias(nodes[i+1])} - } - nodes[levels].Content = []*yaml.Node{yscalar("leaf"), yscalar("v")} - return nodes[0] -} - func pairMap(pairs []nodeview.Pair) map[string]string { out := make(map[string]string, len(pairs)) for _, p := range pairs { @@ -547,14 +516,14 @@ components: {schemas: {A: {*k : *base}}} func TestRefScanCollect_VisitsEachNodeOncePerRole(t *testing.T) { t.Parallel() - ref := ymap(yscalar("$ref"), yscalar("#/components/schemas/B")) - root := ymap(yscalar("schemas"), ymap( - yscalar("A"), ymap(yscalar("allOf"), yseq(yalias(ref), yalias(ref))), - yscalar("B"), ymap(yscalar("properties"), yalias(ref)), - yscalar("C"), yalias(ref), + ref := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/B")) + root := ynode.Map(ynode.Scalar("schemas"), ynode.Map( + ynode.Scalar("A"), ynode.Map(ynode.Scalar("allOf"), ynode.Seq(ynode.Alias(ref), ynode.Alias(ref))), + ynode.Scalar("B"), ynode.Map(ynode.Scalar("properties"), ynode.Alias(ref)), + ynode.Scalar("C"), ynode.Alias(ref), // allOf whose value is the mapping itself, not a sequence: the only way // this node is entered in the schema-list role. - yscalar("D"), ymap(yscalar("allOf"), yalias(ref)), + ynode.Scalar("D"), ynode.Map(ynode.Scalar("allOf"), ynode.Alias(ref)), )) s := newRefScan() @@ -593,19 +562,19 @@ func TestRefScanCollect_UnhandledRolePanics(t *testing.T) { t.Parallel() assert.Panics(t, func() { s := newRefScan() - s.stack = append(s.stack, refTask{n: ymap(), role: roleCount}) + s.stack = append(s.stack, refTask{n: ynode.Map(), role: roleCount}) s.collect(nil) }, "a task carrying an unhandled role is a programmer error") } func TestRefScanCollect_DeepNestingIsNotTruncated(t *testing.T) { t.Parallel() - ref := ymap(yscalar("$ref"), yscalar("#/components/schemas/A")) + ref := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/A")) deep := ref for range maxCycleDepth + 10 { - deep = ymap(yscalar("items"), deep) + deep = ynode.Map(ynode.Scalar("items"), deep) } - root := ymap(yscalar("schemas"), ymap(yscalar("A"), deep)) + root := ynode.Map(ynode.Scalar("schemas"), ynode.Map(ynode.Scalar("A"), deep)) s := newRefScan() s.collect(root) @@ -630,29 +599,15 @@ func TestDetectCycles_ChainedAliasFanOutIsRefusedFast(t *testing.T) { assert.Equal(t, ir.SeverityError, diags[0].Severity) } -func mergeChainSpec(levels int) string { - var b strings.Builder - b.WriteString("openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\nx-anchors:\n") - b.WriteString(" m0: &m0 {type: object}\n") - for i := 1; i <= levels; i++ { - fmt.Fprintf(&b, " m%d: &m%d {<<: *m%d, p%d: %d}\n", i, i, i-1, i, i) - } - b.WriteString("components:\n schemas:\n") - for i := levels; i >= 0; i-- { - fmt.Fprintf(&b, " S%d: {properties: {x: *m%d}}\n", i, i) - } - return b.String() -} - func TestDetectCycles_MergeChainWithinBoundIsClean(t *testing.T) { t.Parallel() - diags := scanWithin(t, mergeChainSpec(nodeview.MergeDepthLimit), "blowup on an in-bound merge chain") + diags := scanWithin(t, ynode.MergeChainSpec(nodeview.MergeDepthLimit), "blowup on an in-bound merge chain") assert.Empty(t, diags, "a merge chain the scan can expand in full is clean") } func TestDetectCycles_MergeChainPastBoundStaysFastAndWarns(t *testing.T) { t.Parallel() - diags := scanWithin(t, mergeChainSpec(1600), "super-linear blowup on a long merge chain") + diags := scanWithin(t, ynode.MergeChainSpec(1600), "super-linear blowup on a long merge chain") require.Len(t, diags, 2, "both the truncation warning and the amplification refusal are reported") assert.Equal(t, diag.CycleScanFailed, diags[0].Code) assert.Equal(t, ir.SeverityWarning, diags[0].Severity, @@ -689,17 +644,17 @@ func scanWithin(t *testing.T, src, blowup string) []ir.Diagnostic { // $ref is collected under no role at all. func TestRefScanCollect_OutsidePositions(t *testing.T) { t.Parallel() - inSeq := ymap(yscalar("$ref"), yscalar("#/components/schemas/A")) - underSchema := ymap(yscalar("$ref"), yscalar("#/components/schemas/B")) - notASchema := ymap(yscalar("$ref"), yscalar("#/components/schemas/C")) + inSeq := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/A")) + underSchema := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/B")) + notASchema := ynode.Map(ynode.Scalar("$ref"), ynode.Scalar("#/components/schemas/C")) - root := ymap( + root := ynode.Map( // a sequence at an outside position: each element stays outside - yscalar("parameters"), yseq(ymap(yscalar("schema"), underSchema)), + ynode.Scalar("parameters"), ynode.Seq(ynode.Map(ynode.Scalar("schema"), underSchema)), // a data key: everything beneath it is data, whatever it looks like - yscalar("example"), notASchema, + ynode.Scalar("example"), notASchema, // a mapping at an outside position that is a reference object itself - yscalar("requestBody"), inSeq, + ynode.Scalar("requestBody"), inSeq, ) s := newRefScan() diff --git a/compilers/openapi/internal/ynode/ynode.go b/compilers/openapi/internal/ynode/ynode.go new file mode 100644 index 00000000..d0ff271f --- /dev/null +++ b/compilers/openapi/internal/ynode/ynode.go @@ -0,0 +1,84 @@ +// Package ynode spells yaml.v3 nodes: the tag a resolved `<<` merge key +// carries, the constructors for the node kinds a parse produces, and the merge +// chain the compiler's depth bounds are measured against. +// +// It sits below nodeview, which reads MergeTag back for its merge-key +// predicate, rather than beside it. Both the view and the cycle scan build +// these nodes from their own internal test files, and an internal test file +// cannot import a package that imports its own — so a home above nodeview would +// be one the view's tests could not reach. Holding the tag here is what lets a +// single definition of Merge serve both. +package ynode + +import ( + "fmt" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +// MergeTag is the tag yaml.v3 resolves every `<<` merge key to, and the exact +// tag speakeasy's yml.IsMergeKey requires before treating one as a merge. +const MergeTag = "!!merge" + +// Scalar builds an untagged plain scalar holding v. +func Scalar(v string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Value: v} +} + +// Map builds a mapping from pairs, given as alternating key and value nodes — +// the layout yaml.v3 gives a mapping's Content. +func Map(pairs ...*yaml.Node) *yaml.Node { + return &yaml.Node{Kind: yaml.MappingNode, Content: pairs} +} + +// Seq builds a sequence holding items. +func Seq(items ...*yaml.Node) *yaml.Node { + return &yaml.Node{Kind: yaml.SequenceNode, Content: items} +} + +// Alias builds an alias resolved to target, as yaml.v3 resolves one to the node +// its anchor names. +func Alias(target *yaml.Node) *yaml.Node { + return &yaml.Node{Kind: yaml.AliasNode, Alias: target} +} + +// Merge builds the `<<` merge key a parse produces, tag included: the same +// scalar without MergeTag is an ordinary key. +func Merge() *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Value: "<<", Tag: MergeTag} +} + +// MergeChain builds levels mappings, each merging the next through `<<` and an +// alias, over a leaf mapping of one pair. Expanding the whole chain +// re-materializes every pair beneath each level, which is the cost the +// merge-depth bound exists to cap. +func MergeChain(levels int) *yaml.Node { + nodes := make([]*yaml.Node, levels+1) + for i := range nodes { + nodes[i] = &yaml.Node{Kind: yaml.MappingNode} + } + for i := range levels { + nodes[i].Content = []*yaml.Node{Merge(), Alias(nodes[i+1])} + } + nodes[levels].Content = []*yaml.Node{Scalar("leaf"), Scalar("v")} + return nodes[0] +} + +// MergeChainSpec is MergeChain in source form: an OpenAPI document whose +// x-anchors nest levels `<<` merges, with one schema per level naming a +// different point on the chain, so a reader that re-expands the chain at every +// reference pays the depth once per schema. +func MergeChainSpec(levels int) string { + var b strings.Builder + b.WriteString("openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\nx-anchors:\n") + b.WriteString(" m0: &m0 {type: object}\n") + for i := 1; i <= levels; i++ { + fmt.Fprintf(&b, " m%d: &m%d {<<: *m%d, p%d: %d}\n", i, i, i-1, i, i) + } + b.WriteString("components:\n schemas:\n") + for i := levels; i >= 0; i-- { + fmt.Fprintf(&b, " S%d: {properties: {x: *m%d}}\n", i, i) + } + return b.String() +} diff --git a/compilers/openapi/internal/ynode/ynode_test.go b/compilers/openapi/internal/ynode/ynode_test.go new file mode 100644 index 00000000..717a4c83 --- /dev/null +++ b/compilers/openapi/internal/ynode/ynode_test.go @@ -0,0 +1,125 @@ +package ynode_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" +) + +func TestScalar_IsUntagged(t *testing.T) { + t.Parallel() + n := ynode.Scalar("x") + assert.Equal(t, yaml.ScalarNode, n.Kind) + assert.Equal(t, "x", n.Value) + assert.Empty(t, n.Tag, "an untagged scalar is what distinguishes a plain key from a merge key") +} + +func TestMap_HoldsPairsAsAlternatingContent(t *testing.T) { + t.Parallel() + key, val := ynode.Scalar("k"), ynode.Scalar("v") + n := ynode.Map(key, val) + assert.Equal(t, yaml.MappingNode, n.Kind) + require.Len(t, n.Content, 2) + assert.Same(t, key, n.Content[0]) + assert.Same(t, val, n.Content[1]) + assert.Empty(t, ynode.Map().Content, "a mapping with no pairs holds nothing") +} + +func TestSeq_HoldsItemsInOrder(t *testing.T) { + t.Parallel() + first, second := ynode.Scalar("a"), ynode.Scalar("b") + n := ynode.Seq(first, second) + assert.Equal(t, yaml.SequenceNode, n.Kind) + require.Len(t, n.Content, 2) + assert.Same(t, first, n.Content[0]) + assert.Same(t, second, n.Content[1]) +} + +func TestAlias_ResolvesToItsTarget(t *testing.T) { + t.Parallel() + target := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) + n := ynode.Alias(target) + assert.Equal(t, yaml.AliasNode, n.Kind) + assert.Same(t, target, n.Alias, "yaml.v3 resolves an alias to the node its anchor names") +} + +// TestMerge_MatchesTheParsedMergeKey pins MergeTag against yaml.v3 rather than +// against itself: the constant is only worth anything if a `<<` key the parser +// produced carries exactly it. +func TestMerge_MatchesTheParsedMergeKey(t *testing.T) { + t.Parallel() + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("b: &b {k: v}\nA:\n <<: *b\n"), &root)) + parsed := findScalar(&root, "<<") + require.NotNil(t, parsed, "no '<<' scalar in the parsed tree") + + built := ynode.Merge() + assert.Equal(t, parsed.Kind, built.Kind) + assert.Equal(t, parsed.Value, built.Value) + assert.Equal(t, parsed.Tag, built.Tag, "the built key carries the tag the parser resolved") + assert.Equal(t, ynode.MergeTag, built.Tag) +} + +func findScalar(n *yaml.Node, value string) *yaml.Node { + if n.Kind == yaml.ScalarNode && n.Value == value { + return n + } + for _, c := range n.Content { + if found := findScalar(c, value); found != nil { + return found + } + } + return nil +} + +func TestMergeChain_NestsOneMergePerLevelOverALeaf(t *testing.T) { + t.Parallel() + const levels = 3 + n := ynode.MergeChain(levels) + + for i := range levels { + require.Equal(t, yaml.MappingNode, n.Kind, "level %d is a mapping", i) + require.Len(t, n.Content, 2, "level %d holds one merge pair", i) + assert.Equal(t, ynode.MergeTag, n.Content[0].Tag, "level %d merges rather than naming a key", i) + require.Equal(t, yaml.AliasNode, n.Content[1].Kind, "level %d merges through an alias", i) + n = n.Content[1].Alias + } + + assert.Equal(t, []string{"leaf", "v"}, []string{n.Content[0].Value, n.Content[1].Value}, + "the chain bottoms out in one ordinary pair") + + bare := ynode.MergeChain(0) + assert.Equal(t, []string{"leaf", "v"}, []string{bare.Content[0].Value, bare.Content[1].Value}, + "a chain of no levels is the leaf alone") +} + +func TestMergeChainSpec_AnchorsEveryLevelAndNamesItFromASchema(t *testing.T) { + t.Parallel() + const levels = 3 + src := ynode.MergeChainSpec(levels) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &root), "the fixture is a parseable document") + + // The preamble is asserted key by key because 3.1 makes paths optional: a + // fixture that stopped writing it would still parse, still compile, and still + // satisfy every caller, so nothing else here would notice it went missing. + assert.Contains(t, src, "openapi: 3.1.0\n", "the fixture declares its version") + assert.Contains(t, src, "info: {title: t, version: '1'}\n", "and the info object 3.1 requires") + assert.Contains(t, src, "paths: {}\n", "and paths, which 3.0 requires and callers pass both versions") + + assert.Contains(t, src, " m0: &m0 {type: object}\n", "the chain starts at an anchored mapping") + for i := 1; i <= levels; i++ { + assert.Contains(t, src, fmt.Sprintf(" m%d: &m%d {<<: *m%d,", i, i, i-1), + "level %d merges the level below it", i) + } + for i := range levels + 1 { + assert.Contains(t, src, fmt.Sprintf(" S%d: {properties: {x: *m%d}}\n", i, i), + "one schema names level %d, so the chain is expanded once per level", i) + } +} diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go index bec94f78..75089da8 100644 --- a/internal/archtest/arch_test.go +++ b/internal/archtest/arch_test.go @@ -58,11 +58,19 @@ var rules = map[string][]string{ // spelling, so a package that could reach the compiler would be able to let a // surrounding schema type change what a literal means. "compilers/openapi/internal/value": {module + "/ir", "gopkg.in/yaml.v3"}, + // The yaml.v3 node vocabulary: the tag a resolved `<<` merge key carries and + // the constructors for the node kinds a parse produces. It reaches yaml and + // nothing else, which is what lets the view below import it rather than the + // other way round — nodeview's own internal tests build these nodes, and an + // internal test file cannot import a package that imports its own. + "compilers/openapi/internal/ynode": {"gopkg.in/yaml.v3"}, // A view over the raw source: mappings read the way the resolver reads them, // through aliases and `<<` merge keys. It reaches ids for the pointer - // unescaping one lookup needs, and is below both the scans that first wanted - // it and the schema lowering that wants the same view. - "compilers/openapi/internal/nodeview": {module + "/compilers/openapi/internal/ids", "gopkg.in/yaml.v3"}, + // unescaping one lookup needs and ynode for the merge tag its key predicate + // tests against, and is below both the scans that first wanted it and the + // schema lowering that wants the same view. + "compilers/openapi/internal/nodeview": {module + "/compilers/openapi/internal/ids", + module + "/compilers/openapi/internal/ynode", "gopkg.in/yaml.v3"}, // The pre-lowering refusals. They read the source through nodeview and report // through diag, and reach no part of the lowering — nothing here has a // document to lower yet.