github.com/gemaraproj/gemara@v1.5.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 = stripHiddenDefBlocks(result)
165
166	result = strings.Replace(result,
167		`#Datetime: time.Format("2006-01-02T15:04:05Z07:00")`,
168		`#Datetime: string`, 1)
169
170	if !strings.Contains(result, "time.") {
171		result = strings.Replace(result, `import "time"`, "", 1)
172	}
173	if !strings.Contains(result, "list.") {
174		result = strings.Replace(result, `import "list"`, "", 1)
175	}
176
177	return result
178}
179
180// stripHiddenDefBlocks removes hidden definition blocks (#_Foo: { ... }) and
181// collapses references to them back to their underlying public type. Hidden
182// definitions are validation-only wrappers (marked @go(-)) whose structural
183// shape causes cross-context subsumption false positives — the same class of
184// noise as time.Format and list.Contains.
185func stripHiddenDefBlocks(content string) string {
186	hiddenDefs := findHiddenDefs(content)
187
188	var out []string
189	lines := strings.Split(content, "\n")
190	i := 0
191	for i < len(lines) {
192		trimmed := strings.TrimSpace(lines[i])
193
194		if strings.HasPrefix(trimmed, "#_") && strings.Contains(trimmed, ":") {
195			defName := strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[0])
196			if _, ok := hiddenDefs[defName]; ok {
197				for len(out) > 0 && strings.HasPrefix(strings.TrimSpace(out[len(out)-1]), "//") {
198					out = out[:len(out)-1]
199				}
200				depth := 0
201				for i < len(lines) {
202					code := strings.TrimSpace(lines[i])
203					if !strings.HasPrefix(code, "//") {
204						for _, ch := range code {
205							if ch == '{' {
206								depth++
207							} else if ch == '}' {
208								depth--
209							}
210						}
211					}
212					i++
213					if depth <= 0 {
214						break
215					}
216				}
217				continue
218			}
219		}
220
221		out = append(out, lines[i])
222		i++
223	}
224	result := strings.Join(out, "\n")
225
226	for name, base := range hiddenDefs {
227		result = strings.ReplaceAll(result, name, base)
228	}
229	return result
230}
231
232// findHiddenDefs scans for #_Foo: { @go(-) } & #Bar patterns and returns
233// a map from hidden name to underlying public type.
234func findHiddenDefs(content string) map[string]string {
235	defs := make(map[string]string)
236	lines := strings.Split(content, "\n")
237	for i, line := range lines {
238		trimmed := strings.TrimSpace(line)
239		if !strings.HasPrefix(trimmed, "#_") || !strings.Contains(trimmed, ":") {
240			continue
241		}
242		name := strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[0])
243		depth := 0
244		for j := i; j < len(lines); j++ {
245			for _, ch := range lines[j] {
246				if ch == '{' {
247					depth++
248				} else if ch == '}' {
249					depth--
250				}
251			}
252			if idx := strings.Index(lines[j], "& #"); idx >= 0 {
253				rest := lines[j][idx+2:]
254				parts := strings.Fields(rest)
255				if len(parts) > 0 {
256					base := strings.TrimRight(parts[0], " &{")
257					defs[name] = base
258					break
259				}
260			}
261			if depth <= 0 && j > i {
262				break
263			}
264		}
265	}
266	return defs
267}
268
269func collectStableDefs(schemaDir string) ([]string, error) {
270	var stableDefs []string
271
272	entries, err := os.ReadDir(schemaDir)
273	if err != nil {
274		return nil, fmt.Errorf("read schema dir: %w", err)
275	}
276
277	for _, entry := range entries {
278		if !strings.HasSuffix(entry.Name(), ".cue") {
279			continue
280		}
281		data, err := os.ReadFile(filepath.Join(schemaDir, entry.Name()))
282		if err != nil {
283			return nil, fmt.Errorf("read %s: %w", entry.Name(), err)
284		}
285		content := string(data)
286		if !strings.Contains(content, `@status("stable")`) {
287			continue
288		}
289		for _, line := range strings.Split(content, "\n") {
290			line = strings.TrimSpace(line)
291			if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#_") && strings.Contains(line, ":") {
292				def := strings.TrimSpace(strings.SplitN(line, ":", 2)[0])
293				stableDefs = append(stableDefs, def)
294			}
295		}
296	}
297	return stableDefs, nil
298}
299
300func latestVersion(ctx context.Context, client *modregistry.Client, modPath string, includePrerelease bool) (module.Version, error) {
301	versions, err := client.ModuleVersions(ctx, modPath+"@v1")
302	if err != nil {
303		return module.Version{}, fmt.Errorf("listing versions for %s: %w", modPath, err)
304	}
305	for i := len(versions) - 1; i >= 0; i-- {
306		v := versions[i]
307		if includePrerelease || semver.Prerelease(v) == "" {
308			return module.NewVersion(modPath, v)
309		}
310	}
311	if includePrerelease {
312		return module.Version{}, fmt.Errorf("no versions found for %s", modPath)
313	}
314	return module.Version{}, fmt.Errorf("no stable release found for %s (set GEMARA_COMPAT_PRERELEASE=true to include pre-releases)", modPath)
315}
316
317func loadModuleFromRegistry(reg modconfig.Registry, ver module.Version) (cue.Value, error) {
318	instances := load.Instances([]string{ver.String()}, &load.Config{
319		Registry: reg,
320	})
321	if len(instances) == 0 {
322		return cue.Value{}, fmt.Errorf("no CUE instances returned for %v", ver)
323	}
324	if err := instances[0].Err; err != nil {
325		return cue.Value{}, fmt.Errorf("loading module %v: %w", ver, err)
326	}
327	val := schemaCtx.BuildInstance(instances[0])
328	if err := val.Err(); err != nil {
329		return cue.Value{}, fmt.Errorf("building schema for %v: %w", ver, err)
330	}
331	return val, nil
332}