From 72c5543c40226fb9c3845c3da9c813cbb8e1a27e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 9 Aug 2026 07:06:27 +0300 Subject: [PATCH 1/2] 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. From bf3bf2a0f16c9a2339e976e8275f704e60959211 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 12 Aug 2026 17:35:37 +0300 Subject: [PATCH 2/2] refactor(compilers/openapi): fold the fifth builder copy into ynode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sourceindex landed while this branch was open, carrying its own copy of the four node builders — byte for byte the ones ynode now defines. The branch consolidated the copies it could see, so merging main left the count at one rather than none, and "define the builders once" no longer described the tree. sourceindex's internal tests build the same nodes for the same reason the view's and the scan's do, and ynode sits below it as it sits below them, so the same argument applies unchanged: the definitions go and the call sites name ynode. Its allowlist is left alone, unlike nodeview's. archtest reads production sources only, and only nodeview imports ynode from one; sourceindex reaches it from an internal test, which the layering check never sees. Listing the edge there would claim a dependency the checked graph does not have. --- .../sourceindex/sourceindex_internal_test.go | 80 ++++++++----------- 1 file changed, 33 insertions(+), 47 deletions(-) diff --git a/compilers/openapi/internal/sourceindex/sourceindex_internal_test.go b/compilers/openapi/internal/sourceindex/sourceindex_internal_test.go index 78ad43a7..f5d8b2f3 100644 --- a/compilers/openapi/internal/sourceindex/sourceindex_internal_test.go +++ b/compilers/openapi/internal/sourceindex/sourceindex_internal_test.go @@ -6,23 +6,9 @@ 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} -} + "github.com/dexpace/morphic/compilers/openapi/internal/ynode" +) // decode is the one place these tests parse source text, so a fixture written as // YAML is indexed over exactly the tree a compile would index. @@ -69,7 +55,7 @@ func TestBuild_WhitespaceOnlySourceIsOneEmptyNode(t *testing.T) { func TestBuild_RootIsTheDocumentsContent(t *testing.T) { t.Parallel() - content := ymap(yscalar("a"), yscalar("1")) + content := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{content}} assert.Same(t, content, Build(doc, MaxIndexedNodes).Root(), @@ -81,9 +67,9 @@ func TestBuild_RootIsTheDocumentsContent(t *testing.T) { func TestBuild_CountsEveryNodeOnce(t *testing.T) { t.Parallel() // root, "a", "1", "b", the sequence, "x", "y" = 7. - root := ymap( - yscalar("a"), yscalar("1"), - yscalar("b"), yseq(yscalar("x"), yscalar("y")), + root := ynode.Map( + ynode.Scalar("a"), ynode.Scalar("1"), + ynode.Scalar("b"), ynode.Seq(ynode.Scalar("x"), ynode.Scalar("y")), ) assert.Equal(t, int64(7), Build(root, MaxIndexedNodes).Nodes()) } @@ -94,10 +80,10 @@ func TestBuild_CountsEveryNodeOnce(t *testing.T) { // would compare a document against itself. func TestBuild_AnAliasCountsOnceAndIsNotFollowed(t *testing.T) { t.Parallel() - base := ymap(yscalar("a"), yscalar("1")) // 3 nodes - root := ymap( - yscalar("base"), base, - yscalar("reuse"), yalias(base), + base := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) // 3 nodes + root := ynode.Map( + ynode.Scalar("base"), base, + ynode.Scalar("reuse"), ynode.Alias(base), ) // root, "base", base's 3, "reuse", the alias = 7. assert.Equal(t, int64(7), Build(root, MaxIndexedNodes).Nodes(), @@ -106,7 +92,7 @@ func TestBuild_AnAliasCountsOnceAndIsNotFollowed(t *testing.T) { func TestBuild_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) idx := Build(root, MaxIndexedNodes) @@ -117,7 +103,7 @@ func TestBuild_NilChildIsSkipped(t *testing.T) { func TestBuild_NonPositiveBoundIndexesNothing(t *testing.T) { t.Parallel() - idx := Build(ymap(yscalar("a"), yscalar("1")), 0) + idx := Build(ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")), 0) assert.True(t, idx.Truncated(), "a bound that admits no node admits no answer either") assert.Equal(t, int64(0), idx.Nodes()) assert.NotNil(t, idx.Root(), "the tree is still named, so a caller can say what it refused") @@ -128,7 +114,7 @@ func TestBuild_NonPositiveBoundIndexesNothing(t *testing.T) { // fact: Truncated is the answer to every other question. func TestBuild_StopsAtItsNodeBound(t *testing.T) { t.Parallel() - root := ymap(yscalar("a"), yscalar("1"), yscalar("b"), yscalar("2")) // 5 nodes + root := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1"), ynode.Scalar("b"), ynode.Scalar("2")) // 5 nodes assert.False(t, Build(root, 5).Truncated(), "a tree exactly at the bound is fully indexed") @@ -143,8 +129,8 @@ func TestBuild_StopsAtItsNodeBound(t *testing.T) { // way a caller could act on. It reports Truncated, and the caller refuses. func TestBuild_TruncationDoesNotMisreportAnAnchorCycle(t *testing.T) { t.Parallel() - root := ymap(yscalar("a"), yscalar("1")) - root.Content = append(root.Content, yscalar("b"), yalias(root)) + root := ynode.Map(ynode.Scalar("a"), ynode.Scalar("1")) + root.Content = append(root.Content, ynode.Scalar("b"), ynode.Alias(root)) full := Build(root, MaxIndexedNodes) _, found := full.AnchorCycle() @@ -156,10 +142,10 @@ func TestBuild_TruncationDoesNotMisreportAnAnchorCycle(t *testing.T) { func TestAnchorCycle_AliasToAnAncestorIsFound(t *testing.T) { t.Parallel() - inner := ymap(yscalar("k"), yscalar("v")) - root := ymap(yscalar("outer"), inner) - alias := yalias(root) - inner.Content = append(inner.Content, yscalar("loop"), alias) + inner := ynode.Map(ynode.Scalar("k"), ynode.Scalar("v")) + root := ynode.Map(ynode.Scalar("outer"), inner) + alias := ynode.Alias(root) + inner.Content = append(inner.Content, ynode.Scalar("loop"), alias) got, found := Build(root, MaxIndexedNodes).AnchorCycle() require.True(t, found, "an alias naming a node it is nested inside expands without bound") @@ -179,7 +165,7 @@ func TestAnchorCycle_LegalReuseIsNotACycle(t *testing.T) { func TestAnchorCycle_AliasWithoutATargetIsNotACycle(t *testing.T) { t.Parallel() - root := ymap(yscalar("k"), &yaml.Node{Kind: yaml.AliasNode}) + root := ynode.Map(ynode.Scalar("k"), &yaml.Node{Kind: yaml.AliasNode}) idx := Build(root, MaxIndexedNodes) _, found := idx.AnchorCycle() @@ -193,11 +179,11 @@ func TestAnchorCycle_AliasWithoutATargetIsNotACycle(t *testing.T) { // shape rather than on what the document says. func TestAnchorCycle_FirstInDocumentOrderWins(t *testing.T) { t.Parallel() - first, second := ymap(), ymap() - root := ymap(yscalar("a"), first, yscalar("b"), second) - firstAlias, secondAlias := yalias(root), yalias(root) - first.Content = append(first.Content, yscalar("loop"), firstAlias) - second.Content = append(second.Content, yscalar("loop"), secondAlias) + first, second := ynode.Map(), ynode.Map() + root := ynode.Map(ynode.Scalar("a"), first, ynode.Scalar("b"), second) + firstAlias, secondAlias := ynode.Alias(root), ynode.Alias(root) + first.Content = append(first.Content, ynode.Scalar("loop"), firstAlias) + second.Content = append(second.Content, ynode.Scalar("loop"), secondAlias) got, found := Build(root, MaxIndexedNodes).AnchorCycle() require.True(t, found) @@ -213,15 +199,15 @@ func TestBuild_TracksAncestorsOnlyToItsDepthBound(t *testing.T) { t.Parallel() // A chain of single-entry mappings: root at depth 0, and each mapping's // value two levels below its parent's. - root := ymap() + root := ynode.Map() deepest := root const links = maxTrackedDepth for range links { - next := ymap() - deepest.Content = append(deepest.Content, yscalar("k"), next) + next := ynode.Map() + deepest.Content = append(deepest.Content, ynode.Scalar("k"), next) deepest = next } - deepest.Content = append(deepest.Content, yscalar("loop"), yalias(root)) + deepest.Content = append(deepest.Content, ynode.Scalar("loop"), ynode.Alias(root)) idx := Build(root, MaxIndexedNodes) assert.False(t, idx.Truncated()) @@ -242,16 +228,16 @@ func TestBuild_TracksAncestorsOnlyToItsDepthBound(t *testing.T) { // case the recursive descent this walk replaced still caught. func TestBuild_TracksAncestorsAtItsDepthBound(t *testing.T) { t.Parallel() - root := ymap() + root := ynode.Map() deepest := root const links = maxTrackedDepth - 1 for range links { - next := ymap() - deepest.Content = append(deepest.Content, yscalar("k"), next) + next := ynode.Map() + deepest.Content = append(deepest.Content, ynode.Scalar("k"), next) deepest = next } // One level shallower than the test above, so these land at the bound itself. - deepest.Content = append(deepest.Content, yscalar("loop"), yalias(root)) + deepest.Content = append(deepest.Content, ynode.Scalar("loop"), ynode.Alias(root)) idx := Build(root, MaxIndexedNodes) assert.False(t, idx.Truncated())