From 72c5543c40226fb9c3845c3da9c813cbb8e1a27e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 9 Aug 2026 07:06:27 +0300 Subject: [PATCH] refactor(compilers/openapi): define the yaml-node builders once internal/nodeview and internal/scan each carried their own copy of the same six yaml-node builders, byte for byte in four cases, and mergeChainSpec was a seventh copy shared between internal/scan and the compiler's own tests. The copies survived because ymerge needs the merge tag and the tag lived in nodeview: a home above nodeview would be one nodeview's own internal tests could not import, since an internal test file cannot import a package that imports its own package. Rather than split the family across two homes, the tag moves down instead. compilers/openapi/internal/ynode holds MergeTag and the constructors that spell the node shapes it names, nodeview imports it for IsMergeKey, and both packages' tests build nodes from the one definition. MergeTag is a fact about yaml.v3 and speakeasy rather than about the view, so it reads no worse one level down, and the predicate that tests for it keeps the comment explaining what the tag means. ynode carries its own tests: without -coverpkg a package is instrumented only by its own test binary, so one with statements and no test files contributes zero-count blocks to the profile and fails the coverage gate. --- compilers/openapi/cycles_test.go | 19 +-- .../openapi/internal/nodeview/nodeview.go | 7 +- .../nodeview/nodeview_internal_test.go | 132 ++++++--------- .../scan/amplification_internal_test.go | 35 ++-- .../internal/scan/scan_internal_test.go | 159 +++++++----------- compilers/openapi/internal/ynode/ynode.go | 84 +++++++++ .../openapi/internal/ynode/ynode_test.go | 125 ++++++++++++++ internal/archtest/arch_test.go | 14 +- 8 files changed, 350 insertions(+), 225 deletions(-) create mode 100644 compilers/openapi/internal/ynode/ynode.go create mode 100644 compilers/openapi/internal/ynode/ynode_test.go 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.