github.com/gemaraproj/gemara@v1.3.0

test/compat_test.go raw

  1// SPDX-License-Identifier: Apache-2.0
  2
  3package schema_test
  4
  5import (
  6	"context"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"testing"
 12
 13	"cuelang.org/go/cue"
 14	"cuelang.org/go/cue/load"
 15	"cuelang.org/go/mod/modconfig"
 16	"cuelang.org/go/mod/modregistry"
 17	"cuelang.org/go/mod/module"
 18	"golang.org/x/mod/semver"
 19)
 20
 21const modulePath = "github.com/gemaraproj/gemara"
 22
 23func TestNoBreakingChanges(t *testing.T) {
 24	ctx := context.Background()
 25
 26	resolver, err := modconfig.NewResolver(&modconfig.Config{
 27		CUERegistry: modconfig.DefaultRegistry,
 28	})
 29	if err != nil {
 30		t.Fatalf("failed to create resolver: %v", err)
 31	}
 32	regClient := modregistry.NewClientWithResolver(resolver)
 33
 34	includePrerelease := os.Getenv("GEMARA_COMPAT_PRERELEASE") == "true"
 35
 36	latestVer, err := latestVersion(ctx, regClient, modulePath, includePrerelease)
 37	if err != nil {
 38		t.Logf("no suitable release found | skipping compatibility check: %v", err)
 39		t.Skip()
 40	}
 41	t.Logf("comparing against released version: %s", latestVer)
 42
 43	reg, err := modconfig.NewRegistry(&modconfig.Config{
 44		CUERegistry: modconfig.DefaultRegistry,
 45	})
 46	if err != nil {
 47		t.Fatalf("failed to create registry: %v", err)
 48	}
 49
 50	oldSchema, err := loadModuleFromRegistry(reg, latestVer)
 51	if err != nil {
 52		t.Fatalf("failed to load released module: %v", err)
 53	}
 54
 55	schemaDir, err := filepath.Abs("..")
 56	if err != nil {
 57		t.Fatalf("failed to resolve schema directory: %v", err)
 58	}
 59
 60	localSchema, err := loadLocalSchemaRelaxed(schemaDir)
 61	if err != nil {
 62		t.Fatalf("failed to load local schema: %v", err)
 63	}
 64
 65	stableDefs, err := collectStableDefs(schemaDir)
 66	if err != nil {
 67		t.Fatalf("failed to collect stable definitions: %v", err)
 68	}
 69	t.Logf("found %d stable definitions to check", len(stableDefs))
 70
 71	for _, defPath := range stableDefs {
 72		defPath := defPath
 73		t.Run(defPath, func(t *testing.T) {
 74			newDef := localSchema.LookupPath(cue.ParsePath(defPath))
 75			if newDef.Err() != nil {
 76				t.Fatalf("new schema: lookup %s: %v", defPath, newDef.Err())
 77			}
 78
 79			oldDef := oldSchema.LookupPath(cue.ParsePath(defPath))
 80			if oldDef.Err() != nil {
 81				t.Logf("definition %s not found in released version (new addition)", defPath)
 82				return
 83			}
 84
 85			if err := newDef.Subsume(oldDef, cue.Raw(), cue.Schema()); err != nil {
 86				t.Errorf("breaking change detected in %s:\n%v", defPath, err)
 87			}
 88		})
 89	}
 90}
 91
 92// loadLocalSchemaRelaxed loads the local CUE schema with builtin validators
 93// and hidden constraint fields relaxed so that CUE's Subsume does not produce
 94// false positives when comparing values from different load contexts
 95// (filesystem vs OCI registry).
 96func loadLocalSchemaRelaxed(schemaDir string) (cue.Value, error) {
 97	entries, err := os.ReadDir(schemaDir)
 98	if err != nil {
 99		return cue.Value{}, fmt.Errorf("read schema dir: %w", err)
100	}
101
102	overlay := make(map[string]load.Source)
103	for _, entry := range entries {
104		if !strings.HasSuffix(entry.Name(), ".cue") {
105			continue
106		}
107		absPath := filepath.Join(schemaDir, entry.Name())
108		original, err := os.ReadFile(absPath)
109		if err != nil {
110			return cue.Value{}, fmt.Errorf("read %s: %w", entry.Name(), err)
111		}
112		relaxed := relaxForSubsume(string(original))
113		if relaxed != string(original) {
114			overlay[absPath] = load.FromString(relaxed)
115		}
116	}
117
118	cfg := &load.Config{
119		Dir:     schemaDir,
120		Overlay: overlay,
121	}
122	instances := load.Instances([]string{"."}, cfg)
123	if len(instances) == 0 {
124		return cue.Value{}, fmt.Errorf("no CUE instances returned")
125	}
126	if err := instances[0].Err; err != nil {
127		return cue.Value{}, fmt.Errorf("loading local schema: %w", err)
128	}
129	val := schemaCtx.BuildInstance(instances[0])
130	if err := val.Err(); err != nil {
131		return cue.Value{}, fmt.Errorf("building local schema: %w", err)
132	}
133	return val, nil
134}
135
136// relaxForSubsume strips builtin validators and hidden constraint fields
137// that cause cross-context Subsume false positives. The time.Format validator
138// and list.Contains-based group validation both fail when compared across
139// independently loaded CUE instances.
140func relaxForSubsume(content string) string {
141	crossContextNoise := []string{
142		"_validGroupIds",
143		"_groupValidation",
144		"_validApplicabilityIds",
145		"_applicabilityValidation",
146		"// Unify the valid ID list with a list.Contains constraint",
147	}
148
149	var lines []string
150	for _, line := range strings.Split(content, "\n") {
151		skip := false
152		for _, p := range crossContextNoise {
153			if strings.Contains(line, p) {
154				skip = true
155				break
156			}
157		}
158		if !skip {
159			lines = append(lines, line)
160		}
161	}
162	result := strings.Join(lines, "\n")
163
164	result = strings.Replace(result,
165		`#Datetime: time.Format("2006-01-02T15:04:05Z07:00")`,
166		`#Datetime: string`, 1)
167
168	if !strings.Contains(result, "time.") {
169		result = strings.Replace(result, `import "time"`, "", 1)
170	}
171	if !strings.Contains(result, "list.") {
172		result = strings.Replace(result, `import "list"`, "", 1)
173	}
174
175	return result
176}
177
178func collectStableDefs(schemaDir string) ([]string, error) {
179	var stableDefs []string
180
181	entries, err := os.ReadDir(schemaDir)
182	if err != nil {
183		return nil, fmt.Errorf("read schema dir: %w", err)
184	}
185
186	for _, entry := range entries {
187		if !strings.HasSuffix(entry.Name(), ".cue") {
188			continue
189		}
190		data, err := os.ReadFile(filepath.Join(schemaDir, entry.Name()))
191		if err != nil {
192			return nil, fmt.Errorf("read %s: %w", entry.Name(), err)
193		}
194		content := string(data)
195		if !strings.Contains(content, `@status("stable")`) {
196			continue
197		}
198		for _, line := range strings.Split(content, "\n") {
199			line = strings.TrimSpace(line)
200			if strings.HasPrefix(line, "#") && strings.Contains(line, ":") {
201				def := strings.TrimSpace(strings.SplitN(line, ":", 2)[0])
202				stableDefs = append(stableDefs, def)
203			}
204		}
205	}
206	return stableDefs, nil
207}
208
209func latestVersion(ctx context.Context, client *modregistry.Client, modPath string, includePrerelease bool) (module.Version, error) {
210	versions, err := client.ModuleVersions(ctx, modPath+"@v1")
211	if err != nil {
212		return module.Version{}, fmt.Errorf("listing versions for %s: %w", modPath, err)
213	}
214	for i := len(versions) - 1; i >= 0; i-- {
215		v := versions[i]
216		if includePrerelease || semver.Prerelease(v) == "" {
217			return module.NewVersion(modPath, v)
218		}
219	}
220	if includePrerelease {
221		return module.Version{}, fmt.Errorf("no versions found for %s", modPath)
222	}
223	return module.Version{}, fmt.Errorf("no stable release found for %s (set GEMARA_COMPAT_PRERELEASE=true to include pre-releases)", modPath)
224}
225
226func loadModuleFromRegistry(reg modconfig.Registry, ver module.Version) (cue.Value, error) {
227	instances := load.Instances([]string{ver.String()}, &load.Config{
228		Registry: reg,
229	})
230	if len(instances) == 0 {
231		return cue.Value{}, fmt.Errorf("no CUE instances returned for %v", ver)
232	}
233	if err := instances[0].Err; err != nil {
234		return cue.Value{}, fmt.Errorf("loading module %v: %w", ver, err)
235	}
236	val := schemaCtx.BuildInstance(instances[0])
237	if err := val.Err(); err != nil {
238		return cue.Value{}, fmt.Errorf("building schema for %v: %w", ver, err)
239	}
240	return val, nil
241}